diff --git a/.agents/skills/translations/SKILL.md b/.agents/skills/translations/SKILL.md new file mode 100644 index 0000000000..532b1c1b10 --- /dev/null +++ b/.agents/skills/translations/SKILL.md @@ -0,0 +1,64 @@ +--- +name: translations +description: Frontend translation workflow using Lingui - extracting, adding, and compiling translations for all supported languages +--- + +# Frontend Translation Workflow (Lingui) + +IMPORTANT: Always update translations as you develop features. When adding new translatable strings, immediately add translations for all supported languages. + +## Core Commands + +```bash +cd frontend + +# Extract translatable strings (use --clean for accurate counts) +yarn messages:extract --clean + +# Compile translations for production +yarn messages:compile + +# Check for untranslated strings +cd scripts && ./list_untranslated_strings.sh +``` + +## Process + +1. **Extract**: `yarn messages:extract --clean` +2. **Check**: Look at the output table for missing translation counts +3. **Add translations**: Update the `.po` files for each language +4. **Verify**: Run extract again to confirm 0 missing +5. **Compile**: `yarn messages:compile` + +## Adding Translations + +Add entries to each locale's `.po` file in `frontend/src/locales/`: + +```po +#: src/path/to/component.tsx:123 +msgid "Your English String" +msgstr "Translated String" +``` + +## Supported Languages + +| Code | Language | +|------|----------| +| en | English (source - no translation needed) | +| de | Deutsch | +| es | Espanol | +| fr | Francais | +| pt | Portugues | +| pt-br | Portugues do Brasil | +| it | Italiano | +| nl | Nederlands | +| zh-cn | Simplified Chinese | +| zh-hk | Traditional Chinese (HK) | +| vi | Tieng Viet | +| ru | Russian (currently untranslated) | + +## Troubleshooting + +- **Counts seem wrong**: Use `--clean` flag to remove obsolete entries +- **Translation not appearing**: Run `yarn messages:compile` after adding +- **Syntax errors**: Check for proper escaping of quotes in `.po` files diff --git a/.claude/skills/translations/SKILL.md b/.claude/skills/translations/SKILL.md index 532b1c1b10..cb9ffeb320 100644 --- a/.claude/skills/translations/SKILL.md +++ b/.claude/skills/translations/SKILL.md @@ -1,64 +1 @@ ---- -name: translations -description: Frontend translation workflow using Lingui - extracting, adding, and compiling translations for all supported languages ---- - -# Frontend Translation Workflow (Lingui) - -IMPORTANT: Always update translations as you develop features. When adding new translatable strings, immediately add translations for all supported languages. - -## Core Commands - -```bash -cd frontend - -# Extract translatable strings (use --clean for accurate counts) -yarn messages:extract --clean - -# Compile translations for production -yarn messages:compile - -# Check for untranslated strings -cd scripts && ./list_untranslated_strings.sh -``` - -## Process - -1. **Extract**: `yarn messages:extract --clean` -2. **Check**: Look at the output table for missing translation counts -3. **Add translations**: Update the `.po` files for each language -4. **Verify**: Run extract again to confirm 0 missing -5. **Compile**: `yarn messages:compile` - -## Adding Translations - -Add entries to each locale's `.po` file in `frontend/src/locales/`: - -```po -#: src/path/to/component.tsx:123 -msgid "Your English String" -msgstr "Translated String" -``` - -## Supported Languages - -| Code | Language | -|------|----------| -| en | English (source - no translation needed) | -| de | Deutsch | -| es | Espanol | -| fr | Francais | -| pt | Portugues | -| pt-br | Portugues do Brasil | -| it | Italiano | -| nl | Nederlands | -| zh-cn | Simplified Chinese | -| zh-hk | Traditional Chinese (HK) | -| vi | Tieng Viet | -| ru | Russian (currently untranslated) | - -## Troubleshooting - -- **Counts seem wrong**: Use `--clean` flag to remove obsolete entries -- **Translation not appearing**: Run `yarn messages:compile` after adding -- **Syntax errors**: Check for proper escaping of quotes in `.po` files +see @../../../.agents/skills/translations/SKILL.md diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000000..2ee358d6f2 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,108 @@ +name: Backend Tests + +on: + push: + branches: [main, develop] + paths: + - 'backend/**' + - '.github/workflows/tests.yml' + pull_request: + paths: + - 'backend/**' + - '.github/workflows/tests.yml' + +jobs: + tests: + runs-on: ubuntu-latest + + strategy: + matrix: + php-versions: ['8.2', '8.3', '8.4'] + + services: + # Postgres is started for the Feature suite. The Unit suite does not need + # a live connection — it only reads DB_DATABASE for the _test guard check + # in CreatesApplication — so it runs in parallel with Postgres warming up. + postgres: + image: postgres:15 + env: + POSTGRES_DB: hievents_test + POSTGRES_USER: hievents + POSTGRES_PASSWORD: hievents + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U hievents -d hievents_test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + # Job-level env. .env.testing supplies the rest, but the DB host on a CI + # runner is 127.0.0.1 (service container exposes its port on the runner), + # not the docker network alias used locally — override here. + env: + DB_HOST: 127.0.0.1 + DB_PORT: 5432 + DB_DATABASE: hievents_test + DB_USERNAME: hievents + DB_PASSWORD: hievents + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: mbstring, xml, ctype, iconv, intl, pdo, pdo_mysql, pdo_pgsql, pgsql, tokenizer + ini-values: post_max_size=256M, upload_max_filesize=256M + coverage: none + + - name: Get Composer Cache Directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer- + + - name: Create Laravel bootstrap cache directory + run: mkdir -p ./backend/bootstrap/cache && chmod -R 777 ./backend/bootstrap/cache + + - name: Install dependencies + run: cd backend && composer install --prefer-dist --no-progress --no-interaction + + - name: Stage .env for testing + # Laravel auto-loads .env.testing when APP_ENV=testing, but artisan + # commands run outside that flow read .env directly. Copy .env.testing + # to .env so both paths see the same config. + run: cp backend/.env.testing backend/.env + + - name: Run Unit test suite + # Pure unit tests — no DB connection, no migrations. The CreatesApplication + # bootstrap detects the absence of DatabaseTransactions / RefreshDatabase + # traits and skips migrate:fresh entirely. Runs in parallel with the + # Postgres service container coming up. + run: cd backend && ./vendor/bin/phpunit --testsuite=Unit --no-coverage + + - name: Wait for Postgres + run: | + for i in {1..30}; do + if pg_isready -h 127.0.0.1 -p 5432 -U hievents -d hievents_test; then + exit 0 + fi + sleep 1 + done + echo "Postgres did not become ready in time" >&2 + exit 1 + + - name: Run Feature test suite + # Integration tests against the real PostgreSQL test database. The first + # test that boots Laravel triggers migrate:fresh once per process via + # CreatesApplication::ensureTestDatabaseIsMigrated. + run: cd backend && ./vendor/bin/phpunit --testsuite=Feature --no-coverage diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml deleted file mode 100644 index 5e4cdf1c6e..0000000000 --- a/.github/workflows/unit-tests.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Run Unit Tests - -on: - push: - branches: [main, develop] - paths: - - 'backend/**' - pull_request: - paths: - - 'backend/**' - -jobs: - run-tests: - runs-on: ubuntu-latest - - strategy: - matrix: - php-versions: ['8.2', '8.3', '8.4'] - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Set up PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-versions }} - extensions: mbstring, xml, ctype, iconv, intl, pdo, pdo_mysql, tokenizer - ini-values: post_max_size=256M, upload_max_filesize=256M - coverage: none - - - name: Get Composer Cache Directory - id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" - - - name: Cache dependencies - uses: actions/cache@v3 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-composer- - - - name: Create Laravel bootstrap cache directory - run: mkdir -p ./backend/bootstrap/cache && chmod -R 777 ./backend/bootstrap/cache - - - name: Install dependencies - run: cd backend && composer install --prefer-dist --no-progress --no-interaction - - - name: Run PHPUnit Tests - run: cd backend && ./vendor/bin/phpunit tests/Unit --no-coverage diff --git a/.gitignore b/.gitignore index eda9758bce..111504f948 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ prompts/ /plans/** /plans + +.claude/worktrees/ diff --git a/CLAUDE.md b/CLAUDE.md index f283207d7f..0b0c572958 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,8 @@ cd docker/development - **DON'T** use `RefreshDatabase` - use `DatabaseTransactions` instead - Unit tests extend Laravel's TestCase, not PHPUnit's TestCase - Use Mockery for mocking +- Tests run against a dedicated `hievents_test` database, configured via `backend/.env.testing` and enforced by `phpunit.xml`. The local docker-compose creates this database automatically via `docker/development/pgsql-init/`. If your existing pgsql volume predates this script, create the DB once with: `docker compose -f docker-compose.dev.yml exec pgsql psql -U username -d backend -c 'CREATE DATABASE hievents_test OWNER username;'` +- Database name **must end in `_test`**. Enforced globally by a `final` guard in `tests/TestCase.php::guardAgainstNonTestDatabase()` which runs on every test that boots Laravel — no per-test opt-in needed and no way to bypass. ### Frontend diff --git a/backend/.env.testing b/backend/.env.testing new file mode 100644 index 0000000000..e559763ef8 --- /dev/null +++ b/backend/.env.testing @@ -0,0 +1,43 @@ +# Auto-loaded by Laravel when APP_ENV=testing (i.e. whenever PHPUnit runs). +# Safe to commit — contains only test-only credentials and fixed test secrets. +# Real secrets must NEVER be added here. + +APP_NAME=Hi.Events +APP_ENV=testing +# Static, test-only AES-256 key. Do not reuse outside tests. +APP_KEY=base64:rasMRv+Gm0oDMcBq+j9MvRgR3a6JYPTZjpRD4rGG2wA= +APP_DEBUG=true +APP_URL=http://localhost +APP_FRONTEND_URL=http://localhost +APP_LOG_QUERIES=false +APP_SAAS_MODE_ENABLED=false + +LOG_CHANNEL=stderr +LOG_LEVEL=debug + +# Database — must end in _test (BaseRepositoryTest enforces this). +# CI exports overrides via the workflow; locally these defaults match the +# docker-compose pgsql service. +DB_CONNECTION=pgsql +DB_HOST=pgsql +DB_PORT=5432 +DB_DATABASE=hievents_test +DB_USERNAME=username +DB_PASSWORD=password + +# Stateless drivers — keep tests hermetic, no external dependencies. +BROADCAST_DRIVER=log +CACHE_DRIVER=array +FILESYSTEM_PUBLIC_DISK=local +FILESYSTEM_PRIVATE_DISK=local +QUEUE_CONNECTION=sync +SESSION_DRIVER=array +SESSION_LIFETIME=120 +MAIL_MAILER=array + +# Fixed test JWT secret — do not reuse outside tests. +JWT_SECRET=test-jwt-secret-not-for-production-use-only-in-tests-aaaaaaaaaa +JWT_ALGO=HS256 + +BCRYPT_ROUNDS=4 +TELESCOPE_ENABLED=false diff --git a/backend/app/Console/Kernel.php b/backend/app/Console/Kernel.php index 540f411478..b07bd8b5ec 100644 --- a/backend/app/Console/Kernel.php +++ b/backend/app/Console/Kernel.php @@ -6,6 +6,8 @@ use HiEvents\Jobs\Waitlist\ProcessExpiredWaitlistOffersJob; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; class Kernel extends ConsoleKernel { @@ -13,11 +15,18 @@ protected function schedule(Schedule $schedule): void { $schedule->job(new SendScheduledMessagesJob)->everyMinute()->withoutOverlapping(); $schedule->job(new ProcessExpiredWaitlistOffersJob)->everyMinute()->withoutOverlapping(); + + $schedule->call(function (): void { + $count = DB::table('failed_jobs')->count(); + if ($count > 0) { + Log::warning('Failed jobs present in queue', ['count' => $count]); + } + })->everyFiveMinutes()->name('failed-jobs-monitor')->withoutOverlapping(); } protected function commands(): void { - $this->load(__DIR__ . '/Commands'); + $this->load(__DIR__.'/Commands'); include base_path('routes/console.php'); } diff --git a/backend/app/DataTransferObjects/UpdateAdminAccountVatSettingDTO.php b/backend/app/DataTransferObjects/UpdateAdminOrganizerVatSettingDTO.php similarity index 60% rename from backend/app/DataTransferObjects/UpdateAdminAccountVatSettingDTO.php rename to backend/app/DataTransferObjects/UpdateAdminOrganizerVatSettingDTO.php index e7103610b0..2e64bebab2 100644 --- a/backend/app/DataTransferObjects/UpdateAdminAccountVatSettingDTO.php +++ b/backend/app/DataTransferObjects/UpdateAdminOrganizerVatSettingDTO.php @@ -2,13 +2,13 @@ namespace HiEvents\DataTransferObjects; -class UpdateAdminAccountVatSettingDTO extends BaseDataObject +class UpdateAdminOrganizerVatSettingDTO extends BaseDataObject { public function __construct( - public readonly int $accountId, - public readonly bool $vatRegistered, + public readonly int $organizerId, + public readonly bool $vatRegistered, public readonly ?string $vatNumber = null, - public readonly ?bool $vatValidated = null, + public readonly ?bool $vatValidated = null, public readonly ?string $businessName = null, public readonly ?string $businessAddress = null, public readonly ?string $vatCountryCode = null, diff --git a/backend/app/DataTransferObjects/UpdateAccountConfigurationDTO.php b/backend/app/DataTransferObjects/UpdateOrganizerConfigurationDTO.php similarity index 58% rename from backend/app/DataTransferObjects/UpdateAccountConfigurationDTO.php rename to backend/app/DataTransferObjects/UpdateOrganizerConfigurationDTO.php index 490085c8fa..4a6cfbada9 100644 --- a/backend/app/DataTransferObjects/UpdateAccountConfigurationDTO.php +++ b/backend/app/DataTransferObjects/UpdateOrganizerConfigurationDTO.php @@ -2,10 +2,10 @@ namespace HiEvents\DataTransferObjects; -class UpdateAccountConfigurationDTO extends BaseDataObject +class UpdateOrganizerConfigurationDTO extends BaseDataObject { public function __construct( - public readonly int $accountId, + public readonly int $organizerId, public readonly array $applicationFees, ) { diff --git a/backend/app/DomainObjects/AccountDomainObject.php b/backend/app/DomainObjects/AccountDomainObject.php index 51920ed3d6..324638ad78 100644 --- a/backend/app/DomainObjects/AccountDomainObject.php +++ b/backend/app/DomainObjects/AccountDomainObject.php @@ -2,62 +2,10 @@ namespace HiEvents\DomainObjects; -use HiEvents\DomainObjects\DTO\AccountApplicationFeeDTO; -use HiEvents\DomainObjects\Enums\StripePlatform; -use Illuminate\Support\Collection; - class AccountDomainObject extends Generated\AccountDomainObjectAbstract { - private ?AccountConfigurationDomainObject $configuration = null; - - /** @var Collection|null */ - private ?Collection $stripePlatforms = null; - - private ?AccountVatSettingDomainObject $accountVatSetting = null; - private ?AccountMessagingTierDomainObject $messagingTier = null; - public function getApplicationFee(): AccountApplicationFeeDTO - { - /** @var AccountConfigurationDomainObject $applicationFee */ - $applicationFee = $this->getConfiguration(); - - return new AccountApplicationFeeDTO( - $applicationFee->getPercentageApplicationFee(), - $applicationFee->getFixedApplicationFee() - ); - } - - public function getConfiguration(): ?AccountConfigurationDomainObject - { - return $this->configuration; - } - - public function setConfiguration(AccountConfigurationDomainObject $configuration): void - { - $this->configuration = $configuration; - } - - public function getAccountStripePlatforms(): ?Collection - { - return $this->stripePlatforms; - } - - public function setAccountStripePlatforms(Collection $stripePlatforms): void - { - $this->stripePlatforms = $stripePlatforms; - } - - public function getAccountVatSetting(): ?AccountVatSettingDomainObject - { - return $this->accountVatSetting; - } - - public function setAccountVatSetting(AccountVatSettingDomainObject $accountVatSetting): void - { - $this->accountVatSetting = $accountVatSetting; - } - public function getMessagingTier(): ?AccountMessagingTierDomainObject { return $this->messagingTier; @@ -67,58 +15,4 @@ public function setMessagingTier(AccountMessagingTierDomainObject $messagingTier { $this->messagingTier = $messagingTier; } - - /** - * Get the primary active Stripe platform for this account - * Returns the platform with setup completed, preferring the most recent - */ - public function getPrimaryStripePlatform(): ?AccountStripePlatformDomainObject - { - if (!$this->stripePlatforms || $this->stripePlatforms->isEmpty()) { - return null; - } - - return $this->stripePlatforms - ->filter(fn($platform) => $platform->getStripeSetupCompletedAt() !== null) - ->sortByDesc(fn($platform) => $platform->getCreatedAt()) - ->first(); - } - - /** - * Get the Stripe platform for a specific platform type - * Handles null platform for open-source installations - */ - public function getStripePlatformByType(?StripePlatform $platformType): ?AccountStripePlatformDomainObject - { - if (!$this->stripePlatforms || $this->stripePlatforms->isEmpty()) { - return null; - } - - return $this->stripePlatforms - ->filter(fn($platform) => $platform->getStripeConnectPlatform() === $platformType?->value) - ->first(); - } - - public function getActiveStripeAccountId(): ?string - { - return $this->getPrimaryStripePlatform()?->getStripeAccountId(); - } - - public function getActiveStripePlatform(): ?StripePlatform - { - $primaryPlatform = $this->getPrimaryStripePlatform(); - if (!$primaryPlatform || !$primaryPlatform->getStripeConnectPlatform()) { - return null; - } - - return StripePlatform::fromString($primaryPlatform->getStripeConnectPlatform()); - } - - /** - * Check if Stripe is set up and ready for payments - */ - public function isStripeSetupComplete(): bool - { - return $this->getPrimaryStripePlatform() !== null; - } } diff --git a/backend/app/DomainObjects/AttendeeDomainObject.php b/backend/app/DomainObjects/AttendeeDomainObject.php index e6396e0f20..c95aa3f5b8 100644 --- a/backend/app/DomainObjects/AttendeeDomainObject.php +++ b/backend/app/DomainObjects/AttendeeDomainObject.php @@ -23,6 +23,8 @@ class AttendeeDomainObject extends Generated\AttendeeDomainObjectAbstract implem /** @var Collection|null */ private ?Collection $checkIns = null; + private ?EventOccurrenceDomainObject $eventOccurrence = null; + public static function getDefaultSort(): string { return self::CREATED_AT; @@ -71,6 +73,7 @@ public static function getAllowedFilterFields(): array self::STATUS, self::PRODUCT_ID, self::PRODUCT_PRICE_ID, + self::EVENT_OCCURRENCE_ID, ]; } @@ -138,4 +141,15 @@ public function getCheckIns(): ?Collection { return $this->checkIns; } + + public function setEventOccurrence(?EventOccurrenceDomainObject $eventOccurrence): AttendeeDomainObject + { + $this->eventOccurrence = $eventOccurrence; + return $this; + } + + public function getEventOccurrence(): ?EventOccurrenceDomainObject + { + return $this->eventOccurrence; + } } diff --git a/backend/app/DomainObjects/CheckInListDomainObject.php b/backend/app/DomainObjects/CheckInListDomainObject.php index ae55f3bbcf..3738c76fd1 100644 --- a/backend/app/DomainObjects/CheckInListDomainObject.php +++ b/backend/app/DomainObjects/CheckInListDomainObject.php @@ -7,12 +7,20 @@ use HiEvents\DomainObjects\SortingAndFiltering\AllowedSorts; use Illuminate\Support\Collection; +/** + * A `null` event_occurrence_id means the list applies to every occurrence of the + * event (the default for recurring events and the only meaningful value for + * single-occurrence events). A non-null value scopes the list to that one + * occurrence — only attendees on that occurrence can be checked in via the list. + */ class CheckInListDomainObject extends Generated\CheckInListDomainObjectAbstract implements IsSortable { private ?Collection $products = null; private ?EventDomainObject $event = null; + private ?EventOccurrenceDomainObject $eventOccurrence = null; + private ?int $checkedInCount = null; private ?int $totalAttendeesCount = null; @@ -77,6 +85,18 @@ public function setEvent(?EventDomainObject $event): static return $this; } + public function getEventOccurrence(): ?EventOccurrenceDomainObject + { + return $this->eventOccurrence; + } + + public function setEventOccurrence(?EventOccurrenceDomainObject $eventOccurrence): static + { + $this->eventOccurrence = $eventOccurrence; + + return $this; + } + public function isExpired(string $timezone): bool { if ($this->getExpiresAt() === null) { diff --git a/backend/app/DomainObjects/DTO/AccountApplicationFeeDTO.php b/backend/app/DomainObjects/DTO/AccountApplicationFeeDTO.php deleted file mode 100644 index 2c36832e00..0000000000 --- a/backend/app/DomainObjects/DTO/AccountApplicationFeeDTO.php +++ /dev/null @@ -1,13 +0,0 @@ - __('Order Confirmation'), self::ATTENDEE_TICKET => __('Attendee Ticket'), + self::OCCURRENCE_CANCELLATION => __('Date Cancellation'), }; } @@ -22,6 +24,16 @@ public function description(): string return match ($this) { self::ORDER_CONFIRMATION => __('Sent to the customer after placing an order'), self::ATTENDEE_TICKET => __('Sent to each attendee with their ticket'), + self::OCCURRENCE_CANCELLATION => __('Sent to attendees when a scheduled date is cancelled'), + }; + } + + public function ctaUrlToken(): string + { + return match ($this) { + self::ORDER_CONFIRMATION => 'order.url', + self::ATTENDEE_TICKET => 'ticket.url', + self::OCCURRENCE_CANCELLATION => 'event.url', }; } } \ No newline at end of file diff --git a/backend/app/DomainObjects/Enums/EventCategory.php b/backend/app/DomainObjects/Enums/EventCategory.php index 1e2a55f11e..680f8d70a1 100644 --- a/backend/app/DomainObjects/Enums/EventCategory.php +++ b/backend/app/DomainObjects/Enums/EventCategory.php @@ -8,7 +8,13 @@ enum EventCategory: string // Community case SOCIAL = 'SOCIAL'; + case FAMILY = 'FAMILY'; + case HOBBIES = 'HOBBIES'; case FOOD_DRINK = 'FOOD_DRINK'; + case WELLNESS = 'WELLNESS'; + case SPIRITUALITY = 'SPIRITUALITY'; + case OUTDOORS = 'OUTDOORS'; + case TOURS = 'TOURS'; case CHARITY = 'CHARITY'; // Creative & Culture @@ -16,6 +22,8 @@ enum EventCategory: string case ART = 'ART'; case COMEDY = 'COMEDY'; case THEATER = 'THEATER'; + case FILM = 'FILM'; + case DANCE = 'DANCE'; // Professional & Learning case BUSINESS = 'BUSINESS'; @@ -26,6 +34,7 @@ enum EventCategory: string // Leisure & Nightlife case SPORTS = 'SPORTS'; case FESTIVAL = 'FESTIVAL'; + case SEASONAL = 'SEASONAL'; case NIGHTLIFE = 'NIGHTLIFE'; // Catch-all @@ -35,18 +44,27 @@ public function label(): string { return match ($this) { self::SOCIAL => __('Social'), + self::FAMILY => __('Family'), + self::HOBBIES => __('Hobbies'), self::FOOD_DRINK => __('Food & Drink'), + self::WELLNESS => __('Wellness'), + self::SPIRITUALITY => __('Spirituality'), + self::OUTDOORS => __('Outdoors'), + self::TOURS => __('Tours'), self::CHARITY => __('Charity'), self::MUSIC => __('Music'), self::ART => __('Art'), self::COMEDY => __('Comedy'), self::THEATER => __('Theater'), + self::FILM => __('Film'), + self::DANCE => __('Dance'), self::BUSINESS => __('Business'), self::TECH => __('Tech'), self::EDUCATION => __('Education'), self::WORKSHOP => __('Workshop'), self::SPORTS => __('Sports'), self::FESTIVAL => __('Festival'), + self::SEASONAL => __('Seasonal'), self::NIGHTLIFE => __('Nightlife'), self::OTHER => __('Other'), }; @@ -56,20 +74,29 @@ public function emoji(): string { return match ($this) { self::SOCIAL => '🤝', + self::FAMILY => '👨‍👩‍👧‍👦', + self::HOBBIES => '🧩', self::FOOD_DRINK => '🍽️', + self::WELLNESS => '🧘', + self::SPIRITUALITY => '🙏', + self::OUTDOORS => '🏞️', + self::TOURS => '🗺️', self::CHARITY => '🎗️', self::MUSIC => '🎵', self::ART => '🎨', self::COMEDY => '😂', self::THEATER => '🎭', + self::FILM => '🎬', + self::DANCE => '💃', self::BUSINESS => '💼', self::TECH => '💻', self::EDUCATION => '📚', self::WORKSHOP => '🛠️', self::SPORTS => '⚽', - self::FESTIVAL => '🎉', + self::FESTIVAL => '🎪', + self::SEASONAL => '🎊', self::NIGHTLIFE => '🪩', - self::OTHER => '📝', + self::OTHER => '🤔', }; } } diff --git a/backend/app/DomainObjects/Enums/EventType.php b/backend/app/DomainObjects/Enums/EventType.php new file mode 100644 index 0000000000..4284a4ed80 --- /dev/null +++ b/backend/app/DomainObjects/Enums/EventType.php @@ -0,0 +1,11 @@ + [ - 'asc' => __('Closest start date'), - 'desc' => __('Furthest start date'), - ], - self::END_DATE => [ - 'asc' => __('Closest end date'), - 'desc' => __('Furthest end date'), - ], self::CREATED_AT => [ 'desc' => __('Newest first'), 'asc' => __('Oldest first'), @@ -79,12 +73,12 @@ public static function getAllowedSorts(): AllowedSorts public static function getDefaultSort(): string { - return self::START_DATE; + return self::CREATED_AT; } public static function getDefaultSortDirection(): string { - return 'asc'; + return 'desc'; } public function setProducts(Collection $products): self @@ -178,58 +172,135 @@ public function getDescriptionPreview(): string return StringHelper::previewFromHtml($this->getDescription()); } + public function setEventOccurrences(?Collection $eventOccurrences): self + { + $this->eventOccurrences = $eventOccurrences; + return $this; + } + + public function getEventOccurrences(): ?Collection + { + return $this->eventOccurrences; + } + + public function getStartDate(): ?string + { + if ($this->eventOccurrences === null || $this->eventOccurrences->isEmpty()) { + return null; + } + + return $this->eventOccurrences->min( + fn(EventOccurrenceDomainObject $o) => $o->getStartDate() + ); + } + + public function getEndDate(): ?string + { + if ($this->eventOccurrences === null || $this->eventOccurrences->isEmpty()) { + return null; + } + + $withEndDates = $this->eventOccurrences->filter( + fn(EventOccurrenceDomainObject $o) => $o->getEndDate() !== null + ); + + if ($withEndDates->isEmpty()) { + return $this->eventOccurrences->max( + fn(EventOccurrenceDomainObject $o) => $o->getStartDate() + ); + } + + return $withEndDates->max( + fn(EventOccurrenceDomainObject $o) => $o->getEndDate() + ); + } + + public function getNextOccurrenceStartDate(): ?string + { + if ($this->eventOccurrences === null || $this->eventOccurrences->isEmpty()) { + return null; + } + + $now = Carbon::now(); + + $nextOccurrence = $this->eventOccurrences + ->filter(fn(EventOccurrenceDomainObject $o) => $o->getStatus() === EventOccurrenceStatus::ACTIVE->name) + ->filter(fn(EventOccurrenceDomainObject $o) => Carbon::parse($o->getStartDate(), 'UTC')->isFuture()) + ->sortBy(fn(EventOccurrenceDomainObject $o) => $o->getStartDate()) + ->first(); + + return $nextOccurrence?->getStartDate(); + } + public function isEventInPast(): bool { - if ($this->getEndDate() === null) { + $endDate = $this->getEndDate(); + if ($endDate === null) { return false; } - $endDate = Carbon::parse($this->getEndDate()); - $endDate->setTimezone($this->getTimezone()); - return $endDate->isPast(); + $parsed = Carbon::parse($endDate); + if ($this->getTimezone()) { + $parsed->setTimezone($this->getTimezone()); + } + + return $parsed->isPast(); } public function isEventInFuture(): bool { - if ($this->getStartDate() === null) { + $startDate = $this->getStartDate(); + if ($startDate === null) { return false; } - $startDate = Carbon::parse($this->getStartDate()); - $startDate->setTimezone($this->getTimezone()); - return $startDate->isFuture(); + $parsed = Carbon::parse($startDate); + if ($this->getTimezone()) { + $parsed->setTimezone($this->getTimezone()); + } + + return $parsed->isFuture(); } public function isEventOngoing(): bool { - $startDate = Carbon::parse($this->getStartDate()); - $startDate->setTimezone($this->getTimezone()); - - if ($this->getEndDate() === null) { - return $startDate->isPast(); + if ($this->eventOccurrences === null || $this->eventOccurrences->isEmpty()) { + return false; } - $endDate = Carbon::parse($this->getEndDate()); - $endDate->setTimezone($this->getTimezone()); + foreach ($this->eventOccurrences as $occurrence) { + if ($occurrence->getStatus() !== EventOccurrenceStatus::ACTIVE->name) { + continue; + } - return $startDate->isPast() && $endDate->isFuture(); + $start = Carbon::parse($occurrence->getStartDate(), 'UTC'); + $end = $occurrence->getEndDate() ? Carbon::parse($occurrence->getEndDate(), 'UTC') : null; + + if ($start->isPast() && ($end === null || $end->isFuture())) { + return true; + } + } + + return false; } public function getLifecycleStatus(): string { - if ($this->isEventInPast()) { - return EventLifecycleStatus::ENDED->name; + if ($this->isEventOngoing()) { + return EventLifecycleStatus::ONGOING->name; } if ($this->isEventInFuture()) { return EventLifecycleStatus::UPCOMING->name; } - if ($this->isEventOngoing()) { - return EventLifecycleStatus::ONGOING->name; - } - return EventLifecycleStatus::ENDED->name; + + } + + public function isRecurring(): bool + { + return $this->getType() === EventType::RECURRING->name; } public function getPromoCodes(): ?Collection diff --git a/backend/app/DomainObjects/EventOccurrenceDailyStatisticDomainObject.php b/backend/app/DomainObjects/EventOccurrenceDailyStatisticDomainObject.php new file mode 100644 index 0000000000..5de6d7ea22 --- /dev/null +++ b/backend/app/DomainObjects/EventOccurrenceDailyStatisticDomainObject.php @@ -0,0 +1,7 @@ + [ + 'asc' => __('Earliest first'), + 'desc' => __('Latest first'), + ], + ] + ); + } + + public static function getDefaultSort(): string + { + return self::START_DATE; + } + + public static function getDefaultSortDirection(): string + { + return 'asc'; + } + + public function setEvent(?EventDomainObject $event): self + { + $this->event = $event; + return $this; + } + + public function getEvent(): ?EventDomainObject + { + return $this->event; + } + + public function setOrderItems(?Collection $orderItems): self + { + $this->orderItems = $orderItems; + return $this; + } + + public function getOrderItems(): ?Collection + { + return $this->orderItems; + } + + public function setAttendees(?Collection $attendees): self + { + $this->attendees = $attendees; + return $this; + } + + public function getAttendees(): ?Collection + { + return $this->attendees; + } + + public function setCheckInLists(?Collection $checkInLists): self + { + $this->checkInLists = $checkInLists; + return $this; + } + + public function getCheckInLists(): ?Collection + { + return $this->checkInLists; + } + + public function setPriceOverrides(?Collection $priceOverrides): self + { + $this->priceOverrides = $priceOverrides; + return $this; + } + + public function getPriceOverrides(): ?Collection + { + return $this->priceOverrides; + } + + public function setEventOccurrenceStatistics(?EventOccurrenceStatisticDomainObject $statistics): self + { + $this->eventOccurrenceStatistics = $statistics; + return $this; + } + + public function getEventOccurrenceStatistics(): ?EventOccurrenceStatisticDomainObject + { + return $this->eventOccurrenceStatistics; + } + + public function isActive(): bool + { + return $this->getStatus() === EventOccurrenceStatus::ACTIVE->name; + } + + public function isCancelled(): bool + { + return $this->getStatus() === EventOccurrenceStatus::CANCELLED->name; + } + + public function isSoldOut(): bool + { + return $this->getStatus() === EventOccurrenceStatus::SOLD_OUT->name; + } + + public function isPast(): bool + { + $endDate = $this->getEndDate() ?? $this->getStartDate(); + return Carbon::parse($endDate, 'UTC')->isPast(); + } + + public function isFuture(): bool + { + return Carbon::parse($this->getStartDate(), 'UTC')->isFuture(); + } + + public function getAvailableCapacity(): ?int + { + if ($this->getCapacity() === null) { + return null; + } + + return max(0, $this->getCapacity() - $this->getUsedCapacity()); + } +} diff --git a/backend/app/DomainObjects/EventOccurrenceStatisticDomainObject.php b/backend/app/DomainObjects/EventOccurrenceStatisticDomainObject.php new file mode 100644 index 0000000000..90acec6eb5 --- /dev/null +++ b/backend/app/DomainObjects/EventOccurrenceStatisticDomainObject.php @@ -0,0 +1,7 @@ + $this->attendee_id ?? null, 'event_id' => $this->event_id ?? null, 'order_id' => $this->order_id ?? null, + 'event_occurrence_id' => $this->event_occurrence_id ?? null, 'short_id' => $this->short_id ?? null, 'ip_address' => $this->ip_address ?? null, 'deleted_at' => $this->deleted_at ?? null, @@ -117,6 +120,17 @@ public function getOrderId(): ?int return $this->order_id; } + public function setEventOccurrenceId(?int $event_occurrence_id): self + { + $this->event_occurrence_id = $event_occurrence_id; + return $this; + } + + public function getEventOccurrenceId(): ?int + { + return $this->event_occurrence_id; + } + public function setShortId(string $short_id): self { $this->short_id = $short_id; diff --git a/backend/app/DomainObjects/Generated/AttendeeDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/AttendeeDomainObjectAbstract.php index be3ca97e0e..3ec48b8b8a 100644 --- a/backend/app/DomainObjects/Generated/AttendeeDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/AttendeeDomainObjectAbstract.php @@ -17,6 +17,7 @@ abstract class AttendeeDomainObjectAbstract extends \HiEvents\DomainObjects\Abst final public const CHECKED_IN_BY = 'checked_in_by'; final public const CHECKED_OUT_BY = 'checked_out_by'; final public const PRODUCT_PRICE_ID = 'product_price_id'; + final public const EVENT_OCCURRENCE_ID = 'event_occurrence_id'; final public const SHORT_ID = 'short_id'; final public const FIRST_NAME = 'first_name'; final public const LAST_NAME = 'last_name'; @@ -37,6 +38,7 @@ abstract class AttendeeDomainObjectAbstract extends \HiEvents\DomainObjects\Abst protected ?int $checked_in_by = null; protected ?int $checked_out_by = null; protected int $product_price_id; + protected ?int $event_occurrence_id = null; protected string $short_id; protected string $first_name = ''; protected string $last_name = ''; @@ -60,6 +62,7 @@ public function toArray(): array 'checked_in_by' => $this->checked_in_by ?? null, 'checked_out_by' => $this->checked_out_by ?? null, 'product_price_id' => $this->product_price_id ?? null, + 'event_occurrence_id' => $this->event_occurrence_id ?? null, 'short_id' => $this->short_id ?? null, 'first_name' => $this->first_name ?? null, 'last_name' => $this->last_name ?? null, @@ -152,6 +155,17 @@ public function getProductPriceId(): int return $this->product_price_id; } + public function setEventOccurrenceId(?int $event_occurrence_id): self + { + $this->event_occurrence_id = $event_occurrence_id; + return $this; + } + + public function getEventOccurrenceId(): ?int + { + return $this->event_occurrence_id; + } + public function setShortId(string $short_id): self { $this->short_id = $short_id; diff --git a/backend/app/DomainObjects/Generated/CheckInListDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/CheckInListDomainObjectAbstract.php index 3ce9ebb1dc..86e0ed7558 100644 --- a/backend/app/DomainObjects/Generated/CheckInListDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/CheckInListDomainObjectAbstract.php @@ -12,6 +12,7 @@ abstract class CheckInListDomainObjectAbstract extends \HiEvents\DomainObjects\A final public const PLURAL_NAME = 'check_in_lists'; final public const ID = 'id'; final public const EVENT_ID = 'event_id'; + final public const EVENT_OCCURRENCE_ID = 'event_occurrence_id'; final public const SHORT_ID = 'short_id'; final public const NAME = 'name'; final public const DESCRIPTION = 'description'; @@ -20,9 +21,14 @@ abstract class CheckInListDomainObjectAbstract extends \HiEvents\DomainObjects\A final public const DELETED_AT = 'deleted_at'; final public const CREATED_AT = 'created_at'; final public const UPDATED_AT = 'updated_at'; + final public const PUBLIC_SHOW_ATTENDEE_NOTES = 'public_show_attendee_notes'; + final public const PUBLIC_SHOW_QUESTION_ANSWERS = 'public_show_question_answers'; + final public const PUBLIC_SHOW_ORDER_DETAILS = 'public_show_order_details'; + final public const IS_SYSTEM_DEFAULT = 'is_system_default'; protected int $id; protected int $event_id; + protected ?int $event_occurrence_id = null; protected string $short_id; protected string $name; protected ?string $description = null; @@ -31,12 +37,17 @@ abstract class CheckInListDomainObjectAbstract extends \HiEvents\DomainObjects\A protected ?string $deleted_at = null; protected ?string $created_at = null; protected ?string $updated_at = null; + protected bool $public_show_attendee_notes = true; + protected bool $public_show_question_answers = true; + protected bool $public_show_order_details = true; + protected bool $is_system_default = false; public function toArray(): array { return [ 'id' => $this->id ?? null, 'event_id' => $this->event_id ?? null, + 'event_occurrence_id' => $this->event_occurrence_id ?? null, 'short_id' => $this->short_id ?? null, 'name' => $this->name ?? null, 'description' => $this->description ?? null, @@ -45,6 +56,10 @@ public function toArray(): array 'deleted_at' => $this->deleted_at ?? null, 'created_at' => $this->created_at ?? null, 'updated_at' => $this->updated_at ?? null, + 'public_show_attendee_notes' => $this->public_show_attendee_notes ?? null, + 'public_show_question_answers' => $this->public_show_question_answers ?? null, + 'public_show_order_details' => $this->public_show_order_details ?? null, + 'is_system_default' => $this->is_system_default ?? null, ]; } @@ -70,6 +85,17 @@ public function getEventId(): int return $this->event_id; } + public function setEventOccurrenceId(?int $event_occurrence_id): self + { + $this->event_occurrence_id = $event_occurrence_id; + return $this; + } + + public function getEventOccurrenceId(): ?int + { + return $this->event_occurrence_id; + } + public function setShortId(string $short_id): self { $this->short_id = $short_id; @@ -157,4 +183,48 @@ public function getUpdatedAt(): ?string { return $this->updated_at; } + + public function setPublicShowAttendeeNotes(bool $public_show_attendee_notes): self + { + $this->public_show_attendee_notes = $public_show_attendee_notes; + return $this; + } + + public function getPublicShowAttendeeNotes(): bool + { + return $this->public_show_attendee_notes; + } + + public function setPublicShowQuestionAnswers(bool $public_show_question_answers): self + { + $this->public_show_question_answers = $public_show_question_answers; + return $this; + } + + public function getPublicShowQuestionAnswers(): bool + { + return $this->public_show_question_answers; + } + + public function setPublicShowOrderDetails(bool $public_show_order_details): self + { + $this->public_show_order_details = $public_show_order_details; + return $this; + } + + public function getPublicShowOrderDetails(): bool + { + return $this->public_show_order_details; + } + + public function setIsSystemDefault(bool $is_system_default): self + { + $this->is_system_default = $is_system_default; + return $this; + } + + public function getIsSystemDefault(): bool + { + return $this->is_system_default; + } } diff --git a/backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php index d40f62026b..8badec5cd6 100644 --- a/backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php @@ -15,8 +15,6 @@ abstract class EventDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac final public const USER_ID = 'user_id'; final public const ORGANIZER_ID = 'organizer_id'; final public const TITLE = 'title'; - final public const START_DATE = 'start_date'; - final public const END_DATE = 'end_date'; final public const DESCRIPTION = 'description'; final public const STATUS = 'status'; final public const LOCATION_DETAILS = 'location_details'; @@ -30,14 +28,14 @@ abstract class EventDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac final public const SHORT_ID = 'short_id'; final public const TICKET_QUANTITY_AVAILABLE = 'ticket_quantity_available'; final public const CATEGORY = 'category'; + final public const TYPE = 'type'; + final public const RECURRENCE_RULE = 'recurrence_rule'; protected int $id; protected int $account_id; protected int $user_id; protected ?int $organizer_id = null; protected string $title; - protected ?string $start_date = null; - protected ?string $end_date = null; protected ?string $description = null; protected ?string $status = null; protected array|string|null $location_details = null; @@ -51,6 +49,8 @@ abstract class EventDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac protected string $short_id; protected ?int $ticket_quantity_available = null; protected string $category = 'OTHER'; + protected string $type = 'SINGLE'; + protected array|string|null $recurrence_rule = null; public function toArray(): array { @@ -60,8 +60,6 @@ public function toArray(): array 'user_id' => $this->user_id ?? null, 'organizer_id' => $this->organizer_id ?? null, 'title' => $this->title ?? null, - 'start_date' => $this->start_date ?? null, - 'end_date' => $this->end_date ?? null, 'description' => $this->description ?? null, 'status' => $this->status ?? null, 'location_details' => $this->location_details ?? null, @@ -75,6 +73,8 @@ public function toArray(): array 'short_id' => $this->short_id ?? null, 'ticket_quantity_available' => $this->ticket_quantity_available ?? null, 'category' => $this->category ?? null, + 'type' => $this->type ?? null, + 'recurrence_rule' => $this->recurrence_rule ?? null, ]; } @@ -133,28 +133,6 @@ public function getTitle(): string return $this->title; } - public function setStartDate(?string $start_date): self - { - $this->start_date = $start_date; - return $this; - } - - public function getStartDate(): ?string - { - return $this->start_date; - } - - public function setEndDate(?string $end_date): self - { - $this->end_date = $end_date; - return $this; - } - - public function getEndDate(): ?string - { - return $this->end_date; - } - public function setDescription(?string $description): self { $this->description = $description; @@ -297,4 +275,26 @@ public function getCategory(): string { return $this->category; } + + public function setType(string $type): self + { + $this->type = $type; + return $this; + } + + public function getType(): string + { + return $this->type; + } + + public function setRecurrenceRule(array|string|null $recurrence_rule): self + { + $this->recurrence_rule = $recurrence_rule; + return $this; + } + + public function getRecurrenceRule(): array|string|null + { + return $this->recurrence_rule; + } } diff --git a/backend/app/DomainObjects/Generated/EventOccurrenceDailyStatisticDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventOccurrenceDailyStatisticDomainObjectAbstract.php new file mode 100644 index 0000000000..5a909a0c05 --- /dev/null +++ b/backend/app/DomainObjects/Generated/EventOccurrenceDailyStatisticDomainObjectAbstract.php @@ -0,0 +1,258 @@ + $this->id ?? null, + 'event_id' => $this->event_id ?? null, + 'event_occurrence_id' => $this->event_occurrence_id ?? null, + 'date' => $this->date ?? null, + 'products_sold' => $this->products_sold ?? null, + 'attendees_registered' => $this->attendees_registered ?? null, + 'sales_total_gross' => $this->sales_total_gross ?? null, + 'sales_total_before_additions' => $this->sales_total_before_additions ?? null, + 'total_tax' => $this->total_tax ?? null, + 'total_fee' => $this->total_fee ?? null, + 'orders_created' => $this->orders_created ?? null, + 'orders_cancelled' => $this->orders_cancelled ?? null, + 'total_refunded' => $this->total_refunded ?? null, + 'version' => $this->version ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + 'deleted_at' => $this->deleted_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setEventId(int $event_id): self + { + $this->event_id = $event_id; + return $this; + } + + public function getEventId(): int + { + return $this->event_id; + } + + public function setEventOccurrenceId(int $event_occurrence_id): self + { + $this->event_occurrence_id = $event_occurrence_id; + return $this; + } + + public function getEventOccurrenceId(): int + { + return $this->event_occurrence_id; + } + + public function setDate(string $date): self + { + $this->date = $date; + return $this; + } + + public function getDate(): string + { + return $this->date; + } + + public function setProductsSold(int $products_sold): self + { + $this->products_sold = $products_sold; + return $this; + } + + public function getProductsSold(): int + { + return $this->products_sold; + } + + public function setAttendeesRegistered(int $attendees_registered): self + { + $this->attendees_registered = $attendees_registered; + return $this; + } + + public function getAttendeesRegistered(): int + { + return $this->attendees_registered; + } + + public function setSalesTotalGross(float $sales_total_gross): self + { + $this->sales_total_gross = $sales_total_gross; + return $this; + } + + public function getSalesTotalGross(): float + { + return $this->sales_total_gross; + } + + public function setSalesTotalBeforeAdditions(float $sales_total_before_additions): self + { + $this->sales_total_before_additions = $sales_total_before_additions; + return $this; + } + + public function getSalesTotalBeforeAdditions(): float + { + return $this->sales_total_before_additions; + } + + public function setTotalTax(float $total_tax): self + { + $this->total_tax = $total_tax; + return $this; + } + + public function getTotalTax(): float + { + return $this->total_tax; + } + + public function setTotalFee(float $total_fee): self + { + $this->total_fee = $total_fee; + return $this; + } + + public function getTotalFee(): float + { + return $this->total_fee; + } + + public function setOrdersCreated(int $orders_created): self + { + $this->orders_created = $orders_created; + return $this; + } + + public function getOrdersCreated(): int + { + return $this->orders_created; + } + + public function setOrdersCancelled(int $orders_cancelled): self + { + $this->orders_cancelled = $orders_cancelled; + return $this; + } + + public function getOrdersCancelled(): int + { + return $this->orders_cancelled; + } + + public function setTotalRefunded(float $total_refunded): self + { + $this->total_refunded = $total_refunded; + return $this; + } + + public function getTotalRefunded(): float + { + return $this->total_refunded; + } + + public function setVersion(int $version): self + { + $this->version = $version; + return $this; + } + + public function getVersion(): int + { + return $this->version; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } +} diff --git a/backend/app/DomainObjects/Generated/EventOccurrenceDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventOccurrenceDomainObjectAbstract.php new file mode 100644 index 0000000000..4c6f465bf5 --- /dev/null +++ b/backend/app/DomainObjects/Generated/EventOccurrenceDomainObjectAbstract.php @@ -0,0 +1,202 @@ + $this->id ?? null, + 'event_id' => $this->event_id ?? null, + 'short_id' => $this->short_id ?? null, + 'start_date' => $this->start_date ?? null, + 'end_date' => $this->end_date ?? null, + 'status' => $this->status ?? null, + 'capacity' => $this->capacity ?? null, + 'used_capacity' => $this->used_capacity ?? null, + 'label' => $this->label ?? null, + 'is_overridden' => $this->is_overridden ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + 'deleted_at' => $this->deleted_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setEventId(int $event_id): self + { + $this->event_id = $event_id; + return $this; + } + + public function getEventId(): int + { + return $this->event_id; + } + + public function setShortId(string $short_id): self + { + $this->short_id = $short_id; + return $this; + } + + public function getShortId(): string + { + return $this->short_id; + } + + public function setStartDate(string $start_date): self + { + $this->start_date = $start_date; + return $this; + } + + public function getStartDate(): string + { + return $this->start_date; + } + + public function setEndDate(?string $end_date): self + { + $this->end_date = $end_date; + return $this; + } + + public function getEndDate(): ?string + { + return $this->end_date; + } + + public function setStatus(string $status): self + { + $this->status = $status; + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setCapacity(?int $capacity): self + { + $this->capacity = $capacity; + return $this; + } + + public function getCapacity(): ?int + { + return $this->capacity; + } + + public function setUsedCapacity(int $used_capacity): self + { + $this->used_capacity = $used_capacity; + return $this; + } + + public function getUsedCapacity(): int + { + return $this->used_capacity; + } + + public function setLabel(?string $label): self + { + $this->label = $label; + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setIsOverridden(bool $is_overridden): self + { + $this->is_overridden = $is_overridden; + return $this; + } + + public function getIsOverridden(): bool + { + return $this->is_overridden; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } +} diff --git a/backend/app/DomainObjects/Generated/EventOccurrenceStatisticDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventOccurrenceStatisticDomainObjectAbstract.php new file mode 100644 index 0000000000..458ef5732f --- /dev/null +++ b/backend/app/DomainObjects/Generated/EventOccurrenceStatisticDomainObjectAbstract.php @@ -0,0 +1,244 @@ + $this->id ?? null, + 'event_id' => $this->event_id ?? null, + 'event_occurrence_id' => $this->event_occurrence_id ?? null, + 'products_sold' => $this->products_sold ?? null, + 'attendees_registered' => $this->attendees_registered ?? null, + 'sales_total_gross' => $this->sales_total_gross ?? null, + 'sales_total_before_additions' => $this->sales_total_before_additions ?? null, + 'total_tax' => $this->total_tax ?? null, + 'total_fee' => $this->total_fee ?? null, + 'orders_created' => $this->orders_created ?? null, + 'orders_cancelled' => $this->orders_cancelled ?? null, + 'total_refunded' => $this->total_refunded ?? null, + 'version' => $this->version ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + 'deleted_at' => $this->deleted_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setEventId(int $event_id): self + { + $this->event_id = $event_id; + return $this; + } + + public function getEventId(): int + { + return $this->event_id; + } + + public function setEventOccurrenceId(int $event_occurrence_id): self + { + $this->event_occurrence_id = $event_occurrence_id; + return $this; + } + + public function getEventOccurrenceId(): int + { + return $this->event_occurrence_id; + } + + public function setProductsSold(int $products_sold): self + { + $this->products_sold = $products_sold; + return $this; + } + + public function getProductsSold(): int + { + return $this->products_sold; + } + + public function setAttendeesRegistered(int $attendees_registered): self + { + $this->attendees_registered = $attendees_registered; + return $this; + } + + public function getAttendeesRegistered(): int + { + return $this->attendees_registered; + } + + public function setSalesTotalGross(float $sales_total_gross): self + { + $this->sales_total_gross = $sales_total_gross; + return $this; + } + + public function getSalesTotalGross(): float + { + return $this->sales_total_gross; + } + + public function setSalesTotalBeforeAdditions(float $sales_total_before_additions): self + { + $this->sales_total_before_additions = $sales_total_before_additions; + return $this; + } + + public function getSalesTotalBeforeAdditions(): float + { + return $this->sales_total_before_additions; + } + + public function setTotalTax(float $total_tax): self + { + $this->total_tax = $total_tax; + return $this; + } + + public function getTotalTax(): float + { + return $this->total_tax; + } + + public function setTotalFee(float $total_fee): self + { + $this->total_fee = $total_fee; + return $this; + } + + public function getTotalFee(): float + { + return $this->total_fee; + } + + public function setOrdersCreated(int $orders_created): self + { + $this->orders_created = $orders_created; + return $this; + } + + public function getOrdersCreated(): int + { + return $this->orders_created; + } + + public function setOrdersCancelled(int $orders_cancelled): self + { + $this->orders_cancelled = $orders_cancelled; + return $this; + } + + public function getOrdersCancelled(): int + { + return $this->orders_cancelled; + } + + public function setTotalRefunded(float $total_refunded): self + { + $this->total_refunded = $total_refunded; + return $this; + } + + public function getTotalRefunded(): float + { + return $this->total_refunded; + } + + public function setVersion(int $version): self + { + $this->version = $version; + return $this; + } + + public function getVersion(): int + { + return $this->version; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } +} diff --git a/backend/app/DomainObjects/Generated/MessageDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/MessageDomainObjectAbstract.php index 30fdcfcff0..582014db76 100644 --- a/backend/app/DomainObjects/Generated/MessageDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/MessageDomainObjectAbstract.php @@ -13,6 +13,7 @@ abstract class MessageDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr final public const ID = 'id'; final public const EVENT_ID = 'event_id'; final public const SENT_BY_USER_ID = 'sent_by_user_id'; + final public const EVENT_OCCURRENCE_ID = 'event_occurrence_id'; final public const SUBJECT = 'subject'; final public const MESSAGE = 'message'; final public const TYPE = 'type'; @@ -32,6 +33,7 @@ abstract class MessageDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr protected int $id; protected int $event_id; protected int $sent_by_user_id; + protected ?int $event_occurrence_id = null; protected string $subject; protected string $message; protected string $type; @@ -54,6 +56,7 @@ public function toArray(): array 'id' => $this->id ?? null, 'event_id' => $this->event_id ?? null, 'sent_by_user_id' => $this->sent_by_user_id ?? null, + 'event_occurrence_id' => $this->event_occurrence_id ?? null, 'subject' => $this->subject ?? null, 'message' => $this->message ?? null, 'type' => $this->type ?? null, @@ -105,6 +108,17 @@ public function getSentByUserId(): int return $this->sent_by_user_id; } + public function setEventOccurrenceId(?int $event_occurrence_id): self + { + $this->event_occurrence_id = $event_occurrence_id; + return $this; + } + + public function getEventOccurrenceId(): ?int + { + return $this->event_occurrence_id; + } + public function setSubject(string $subject): self { $this->subject = $subject; diff --git a/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php index 076da8954c..b50ba6ecfc 100644 --- a/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php @@ -14,6 +14,7 @@ abstract class OrderItemDomainObjectAbstract extends \HiEvents\DomainObjects\Abs final public const ORDER_ID = 'order_id'; final public const PRODUCT_ID = 'product_id'; final public const PRODUCT_PRICE_ID = 'product_price_id'; + final public const EVENT_OCCURRENCE_ID = 'event_occurrence_id'; final public const TOTAL_BEFORE_ADDITIONS = 'total_before_additions'; final public const QUANTITY = 'quantity'; final public const ITEM_NAME = 'item_name'; @@ -30,6 +31,7 @@ abstract class OrderItemDomainObjectAbstract extends \HiEvents\DomainObjects\Abs protected int $order_id; protected int $product_id; protected int $product_price_id; + protected ?int $event_occurrence_id = null; protected float $total_before_additions; protected int $quantity; protected ?string $item_name = null; @@ -49,6 +51,7 @@ public function toArray(): array 'order_id' => $this->order_id ?? null, 'product_id' => $this->product_id ?? null, 'product_price_id' => $this->product_price_id ?? null, + 'event_occurrence_id' => $this->event_occurrence_id ?? null, 'total_before_additions' => $this->total_before_additions ?? null, 'quantity' => $this->quantity ?? null, 'item_name' => $this->item_name ?? null, @@ -107,6 +110,17 @@ public function getProductPriceId(): int return $this->product_price_id; } + public function setEventOccurrenceId(?int $event_occurrence_id): self + { + $this->event_occurrence_id = $event_occurrence_id; + return $this; + } + + public function getEventOccurrenceId(): ?int + { + return $this->event_occurrence_id; + } + public function setTotalBeforeAdditions(float $total_before_additions): self { $this->total_before_additions = $total_before_additions; diff --git a/backend/app/DomainObjects/Generated/OrganizerConfigurationDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrganizerConfigurationDomainObjectAbstract.php new file mode 100644 index 0000000000..229df661c5 --- /dev/null +++ b/backend/app/DomainObjects/Generated/OrganizerConfigurationDomainObjectAbstract.php @@ -0,0 +1,146 @@ + $this->id ?? null, + 'name' => $this->name ?? null, + 'is_system_default' => $this->is_system_default ?? null, + 'application_fees' => $this->application_fees ?? null, + 'bypass_application_fees' => $this->bypass_application_fees ?? null, + 'legacy_account_configuration_id' => $this->legacy_account_configuration_id ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + 'deleted_at' => $this->deleted_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setName(string $name): self + { + $this->name = $name; + return $this; + } + + public function getName(): string + { + return $this->name; + } + + public function setIsSystemDefault(bool $is_system_default): self + { + $this->is_system_default = $is_system_default; + return $this; + } + + public function getIsSystemDefault(): bool + { + return $this->is_system_default; + } + + public function setApplicationFees(array|string|null $application_fees): self + { + $this->application_fees = $application_fees; + return $this; + } + + public function getApplicationFees(): array|string|null + { + return $this->application_fees; + } + + public function setBypassApplicationFees(bool $bypass_application_fees): self + { + $this->bypass_application_fees = $bypass_application_fees; + return $this; + } + + public function getBypassApplicationFees(): bool + { + return $this->bypass_application_fees; + } + + public function setLegacyAccountConfigurationId(?int $legacy_account_configuration_id): self + { + $this->legacy_account_configuration_id = $legacy_account_configuration_id; + return $this; + } + + public function getLegacyAccountConfigurationId(): ?int + { + return $this->legacy_account_configuration_id; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } +} diff --git a/backend/app/DomainObjects/Generated/OrganizerDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrganizerDomainObjectAbstract.php index dc6d66aab5..6738021dfd 100644 --- a/backend/app/DomainObjects/Generated/OrganizerDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/OrganizerDomainObjectAbstract.php @@ -12,6 +12,7 @@ abstract class OrganizerDomainObjectAbstract extends \HiEvents\DomainObjects\Abs final public const PLURAL_NAME = 'organizers'; final public const ID = 'id'; final public const ACCOUNT_ID = 'account_id'; + final public const ORGANIZER_CONFIGURATION_ID = 'organizer_configuration_id'; final public const NAME = 'name'; final public const EMAIL = 'email'; final public const PHONE = 'phone'; @@ -26,6 +27,7 @@ abstract class OrganizerDomainObjectAbstract extends \HiEvents\DomainObjects\Abs protected int $id; protected int $account_id; + protected ?int $organizer_configuration_id = null; protected string $name; protected string $email; protected ?string $phone = null; @@ -43,6 +45,7 @@ public function toArray(): array return [ 'id' => $this->id ?? null, 'account_id' => $this->account_id ?? null, + 'organizer_configuration_id' => $this->organizer_configuration_id ?? null, 'name' => $this->name ?? null, 'email' => $this->email ?? null, 'phone' => $this->phone ?? null, @@ -79,6 +82,17 @@ public function getAccountId(): int return $this->account_id; } + public function setOrganizerConfigurationId(?int $organizer_configuration_id): self + { + $this->organizer_configuration_id = $organizer_configuration_id; + return $this; + } + + public function getOrganizerConfigurationId(): ?int + { + return $this->organizer_configuration_id; + } + public function setName(string $name): self { $this->name = $name; diff --git a/backend/app/DomainObjects/Generated/OrganizerStripePlatformDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrganizerStripePlatformDomainObjectAbstract.php new file mode 100644 index 0000000000..88fe91ce03 --- /dev/null +++ b/backend/app/DomainObjects/Generated/OrganizerStripePlatformDomainObjectAbstract.php @@ -0,0 +1,160 @@ + $this->id ?? null, + 'organizer_id' => $this->organizer_id ?? null, + 'stripe_connect_account_type' => $this->stripe_connect_account_type ?? null, + 'stripe_connect_platform' => $this->stripe_connect_platform ?? null, + 'stripe_account_id' => $this->stripe_account_id ?? null, + 'stripe_setup_completed_at' => $this->stripe_setup_completed_at ?? null, + 'stripe_account_details' => $this->stripe_account_details ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + 'deleted_at' => $this->deleted_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setOrganizerId(int $organizer_id): self + { + $this->organizer_id = $organizer_id; + return $this; + } + + public function getOrganizerId(): int + { + return $this->organizer_id; + } + + public function setStripeConnectAccountType(?string $stripe_connect_account_type): self + { + $this->stripe_connect_account_type = $stripe_connect_account_type; + return $this; + } + + public function getStripeConnectAccountType(): ?string + { + return $this->stripe_connect_account_type; + } + + public function setStripeConnectPlatform(?string $stripe_connect_platform): self + { + $this->stripe_connect_platform = $stripe_connect_platform; + return $this; + } + + public function getStripeConnectPlatform(): ?string + { + return $this->stripe_connect_platform; + } + + public function setStripeAccountId(?string $stripe_account_id): self + { + $this->stripe_account_id = $stripe_account_id; + return $this; + } + + public function getStripeAccountId(): ?string + { + return $this->stripe_account_id; + } + + public function setStripeSetupCompletedAt(?string $stripe_setup_completed_at): self + { + $this->stripe_setup_completed_at = $stripe_setup_completed_at; + return $this; + } + + public function getStripeSetupCompletedAt(): ?string + { + return $this->stripe_setup_completed_at; + } + + public function setStripeAccountDetails(array|string|null $stripe_account_details): self + { + $this->stripe_account_details = $stripe_account_details; + return $this; + } + + public function getStripeAccountDetails(): array|string|null + { + return $this->stripe_account_details; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } +} diff --git a/backend/app/DomainObjects/Generated/OrganizerVatSettingDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrganizerVatSettingDomainObjectAbstract.php new file mode 100644 index 0000000000..6d5dbfac78 --- /dev/null +++ b/backend/app/DomainObjects/Generated/OrganizerVatSettingDomainObjectAbstract.php @@ -0,0 +1,230 @@ + $this->id ?? null, + 'organizer_id' => $this->organizer_id ?? null, + 'vat_registered' => $this->vat_registered ?? null, + 'vat_number' => $this->vat_number ?? null, + 'vat_validated' => $this->vat_validated ?? null, + 'vat_validation_status' => $this->vat_validation_status ?? null, + 'vat_validation_error' => $this->vat_validation_error ?? null, + 'vat_validation_attempts' => $this->vat_validation_attempts ?? null, + 'vat_validation_date' => $this->vat_validation_date ?? null, + 'business_name' => $this->business_name ?? null, + 'business_address' => $this->business_address ?? null, + 'vat_country_code' => $this->vat_country_code ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + 'deleted_at' => $this->deleted_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setOrganizerId(int $organizer_id): self + { + $this->organizer_id = $organizer_id; + return $this; + } + + public function getOrganizerId(): int + { + return $this->organizer_id; + } + + public function setVatRegistered(bool $vat_registered): self + { + $this->vat_registered = $vat_registered; + return $this; + } + + public function getVatRegistered(): bool + { + return $this->vat_registered; + } + + public function setVatNumber(?string $vat_number): self + { + $this->vat_number = $vat_number; + return $this; + } + + public function getVatNumber(): ?string + { + return $this->vat_number; + } + + public function setVatValidated(bool $vat_validated): self + { + $this->vat_validated = $vat_validated; + return $this; + } + + public function getVatValidated(): bool + { + return $this->vat_validated; + } + + public function setVatValidationStatus(string $vat_validation_status): self + { + $this->vat_validation_status = $vat_validation_status; + return $this; + } + + public function getVatValidationStatus(): string + { + return $this->vat_validation_status; + } + + public function setVatValidationError(?string $vat_validation_error): self + { + $this->vat_validation_error = $vat_validation_error; + return $this; + } + + public function getVatValidationError(): ?string + { + return $this->vat_validation_error; + } + + public function setVatValidationAttempts(int $vat_validation_attempts): self + { + $this->vat_validation_attempts = $vat_validation_attempts; + return $this; + } + + public function getVatValidationAttempts(): int + { + return $this->vat_validation_attempts; + } + + public function setVatValidationDate(?string $vat_validation_date): self + { + $this->vat_validation_date = $vat_validation_date; + return $this; + } + + public function getVatValidationDate(): ?string + { + return $this->vat_validation_date; + } + + public function setBusinessName(?string $business_name): self + { + $this->business_name = $business_name; + return $this; + } + + public function getBusinessName(): ?string + { + return $this->business_name; + } + + public function setBusinessAddress(?string $business_address): self + { + $this->business_address = $business_address; + return $this; + } + + public function getBusinessAddress(): ?string + { + return $this->business_address; + } + + public function setVatCountryCode(?string $vat_country_code): self + { + $this->vat_country_code = $vat_country_code; + return $this; + } + + public function getVatCountryCode(): ?string + { + return $this->vat_country_code; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } +} diff --git a/backend/app/DomainObjects/Generated/ProductOccurrenceVisibilityDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/ProductOccurrenceVisibilityDomainObjectAbstract.php new file mode 100644 index 0000000000..be47ce38b7 --- /dev/null +++ b/backend/app/DomainObjects/Generated/ProductOccurrenceVisibilityDomainObjectAbstract.php @@ -0,0 +1,76 @@ + $this->id ?? null, + 'event_occurrence_id' => $this->event_occurrence_id ?? null, + 'product_id' => $this->product_id ?? null, + 'created_at' => $this->created_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setEventOccurrenceId(int $event_occurrence_id): self + { + $this->event_occurrence_id = $event_occurrence_id; + return $this; + } + + public function getEventOccurrenceId(): int + { + return $this->event_occurrence_id; + } + + public function setProductId(int $product_id): self + { + $this->product_id = $product_id; + return $this; + } + + public function getProductId(): int + { + return $this->product_id; + } + + public function setCreatedAt(string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): string + { + return $this->created_at; + } +} diff --git a/backend/app/DomainObjects/Generated/ProductPriceOccurrenceOverrideDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/ProductPriceOccurrenceOverrideDomainObjectAbstract.php new file mode 100644 index 0000000000..55a86b22b7 --- /dev/null +++ b/backend/app/DomainObjects/Generated/ProductPriceOccurrenceOverrideDomainObjectAbstract.php @@ -0,0 +1,104 @@ + $this->id ?? null, + 'event_occurrence_id' => $this->event_occurrence_id ?? null, + 'product_price_id' => $this->product_price_id ?? null, + 'price' => $this->price ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setEventOccurrenceId(int $event_occurrence_id): self + { + $this->event_occurrence_id = $event_occurrence_id; + return $this; + } + + public function getEventOccurrenceId(): int + { + return $this->event_occurrence_id; + } + + public function setProductPriceId(int $product_price_id): self + { + $this->product_price_id = $product_price_id; + return $this; + } + + public function getProductPriceId(): int + { + return $this->product_price_id; + } + + public function setPrice(float $price): self + { + $this->price = $price; + return $this; + } + + public function getPrice(): float + { + return $this->price; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } +} diff --git a/backend/app/DomainObjects/Generated/WaitlistEntryDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/WaitlistEntryDomainObjectAbstract.php index 7c310634e1..cf0175a573 100644 --- a/backend/app/DomainObjects/Generated/WaitlistEntryDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/WaitlistEntryDomainObjectAbstract.php @@ -14,6 +14,7 @@ abstract class WaitlistEntryDomainObjectAbstract extends \HiEvents\DomainObjects final public const EVENT_ID = 'event_id'; final public const PRODUCT_PRICE_ID = 'product_price_id'; final public const ORDER_ID = 'order_id'; + final public const EVENT_OCCURRENCE_ID = 'event_occurrence_id'; final public const EMAIL = 'email'; final public const FIRST_NAME = 'first_name'; final public const LAST_NAME = 'last_name'; @@ -34,6 +35,7 @@ abstract class WaitlistEntryDomainObjectAbstract extends \HiEvents\DomainObjects protected int $event_id; protected int $product_price_id; protected ?int $order_id = null; + protected ?int $event_occurrence_id = null; protected string $email; protected string $first_name; protected ?string $last_name = null; @@ -57,6 +59,7 @@ public function toArray(): array 'event_id' => $this->event_id ?? null, 'product_price_id' => $this->product_price_id ?? null, 'order_id' => $this->order_id ?? null, + 'event_occurrence_id' => $this->event_occurrence_id ?? null, 'email' => $this->email ?? null, 'first_name' => $this->first_name ?? null, 'last_name' => $this->last_name ?? null, @@ -119,6 +122,17 @@ public function getOrderId(): ?int return $this->order_id; } + public function setEventOccurrenceId(?int $event_occurrence_id): self + { + $this->event_occurrence_id = $event_occurrence_id; + return $this; + } + + public function getEventOccurrenceId(): ?int + { + return $this->event_occurrence_id; + } + public function setEmail(string $email): self { $this->email = $email; diff --git a/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php index 660f7a66f5..8e301915fd 100644 --- a/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php @@ -13,8 +13,8 @@ abstract class WebhookDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr final public const ID = 'id'; final public const USER_ID = 'user_id'; final public const EVENT_ID = 'event_id'; - final public const ORGANIZER_ID = 'organizer_id'; final public const ACCOUNT_ID = 'account_id'; + final public const ORGANIZER_ID = 'organizer_id'; final public const URL = 'url'; final public const EVENT_TYPES = 'event_types'; final public const LAST_RESPONSE_CODE = 'last_response_code'; @@ -29,8 +29,8 @@ abstract class WebhookDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr protected int $id; protected int $user_id; protected ?int $event_id = null; - protected ?int $organizer_id = null; protected int $account_id; + protected ?int $organizer_id = null; protected string $url; protected array|string $event_types; protected ?int $last_response_code = null; @@ -48,8 +48,8 @@ public function toArray(): array 'id' => $this->id ?? null, 'user_id' => $this->user_id ?? null, 'event_id' => $this->event_id ?? null, - 'organizer_id' => $this->organizer_id ?? null, 'account_id' => $this->account_id ?? null, + 'organizer_id' => $this->organizer_id ?? null, 'url' => $this->url ?? null, 'event_types' => $this->event_types ?? null, 'last_response_code' => $this->last_response_code ?? null, @@ -96,26 +96,26 @@ public function getEventId(): ?int return $this->event_id; } - public function setOrganizerId(?int $organizer_id): self + public function setAccountId(int $account_id): self { - $this->organizer_id = $organizer_id; + $this->account_id = $account_id; return $this; } - public function getOrganizerId(): ?int + public function getAccountId(): int { - return $this->organizer_id; + return $this->account_id; } - public function setAccountId(int $account_id): self + public function setOrganizerId(?int $organizer_id): self { - $this->account_id = $account_id; + $this->organizer_id = $organizer_id; return $this; } - public function getAccountId(): int + public function getOrganizerId(): ?int { - return $this->account_id; + return $this->organizer_id; } public function setUrl(string $url): self diff --git a/backend/app/DomainObjects/OrderItemDomainObject.php b/backend/app/DomainObjects/OrderItemDomainObject.php index 164b1d9c02..33b3db9e83 100644 --- a/backend/app/DomainObjects/OrderItemDomainObject.php +++ b/backend/app/DomainObjects/OrderItemDomainObject.php @@ -12,6 +12,8 @@ class OrderItemDomainObject extends Generated\OrderItemDomainObjectAbstract public ?OrderDomainObject $order = null; + private ?EventOccurrenceDomainObject $eventOccurrence = null; + public function getTotalBeforeDiscount(): float { return Currency::round($this->getPriceBeforeDiscount() * $this->getQuantity()); @@ -52,4 +54,16 @@ public function setOrder(?OrderDomainObject $order): self return $this; } + + public function getEventOccurrence(): ?EventOccurrenceDomainObject + { + return $this->eventOccurrence; + } + + public function setEventOccurrence(?EventOccurrenceDomainObject $eventOccurrence): self + { + $this->eventOccurrence = $eventOccurrence; + + return $this; + } } diff --git a/backend/app/DomainObjects/OrganizerConfigurationDomainObject.php b/backend/app/DomainObjects/OrganizerConfigurationDomainObject.php new file mode 100644 index 0000000000..01e85760ac --- /dev/null +++ b/backend/app/DomainObjects/OrganizerConfigurationDomainObject.php @@ -0,0 +1,21 @@ +getApplicationFees()['fixed'] ?? config('app.default_application_fee_fixed'); + } + + public function getPercentageApplicationFee(): float + { + return $this->getApplicationFees()['percentage'] ?? config('app.default_application_fee_percentage'); + } + + public function getApplicationFeeCurrency(): string + { + return $this->getApplicationFees()['currency'] ?? 'USD'; + } +} diff --git a/backend/app/DomainObjects/OrganizerDomainObject.php b/backend/app/DomainObjects/OrganizerDomainObject.php index 6164c5f2cd..3f432f5f83 100644 --- a/backend/app/DomainObjects/OrganizerDomainObject.php +++ b/backend/app/DomainObjects/OrganizerDomainObject.php @@ -2,6 +2,7 @@ namespace HiEvents\DomainObjects; +use HiEvents\DomainObjects\Enums\StripePlatform; use Illuminate\Support\Collection; use Illuminate\Support\Str; @@ -16,6 +17,13 @@ class OrganizerDomainObject extends Generated\OrganizerDomainObjectAbstract private ?OrganizerSettingDomainObject $settings = null; + /** @var Collection|null */ + private ?Collection $stripePlatforms = null; + + private ?OrganizerVatSettingDomainObject $vatSetting = null; + + private ?OrganizerConfigurationDomainObject $configuration = null; + public function getImages(): ?Collection { return $this->images; @@ -52,8 +60,87 @@ public function setOrganizerSettings(?OrganizerSettingDomainObject $settings): s return $this; } + public function getOrganizerStripePlatforms(): ?Collection + { + return $this->stripePlatforms; + } + + public function setOrganizerStripePlatforms(Collection $stripePlatforms): self + { + $this->stripePlatforms = $stripePlatforms; + + return $this; + } + public function getSlug(): string { return Str::slug($this->name); } + + public function getPrimaryStripePlatform(): ?OrganizerStripePlatformDomainObject + { + if (!$this->stripePlatforms || $this->stripePlatforms->isEmpty()) { + return null; + } + + return $this->stripePlatforms + ->filter(fn($platform) => $platform->getStripeSetupCompletedAt() !== null) + ->sortByDesc(fn($platform) => $platform->getCreatedAt()) + ->first(); + } + + public function getStripePlatformByType(?StripePlatform $platformType): ?OrganizerStripePlatformDomainObject + { + if (!$this->stripePlatforms || $this->stripePlatforms->isEmpty()) { + return null; + } + + return $this->stripePlatforms + ->filter(fn($platform) => $platform->getStripeConnectPlatform() === $platformType?->value) + ->first(); + } + + public function getActiveStripeAccountId(): ?string + { + return $this->getPrimaryStripePlatform()?->getStripeAccountId(); + } + + public function getActiveStripePlatform(): ?StripePlatform + { + $primaryPlatform = $this->getPrimaryStripePlatform(); + if (!$primaryPlatform || !$primaryPlatform->getStripeConnectPlatform()) { + return null; + } + + return StripePlatform::fromString($primaryPlatform->getStripeConnectPlatform()); + } + + public function isStripeSetupComplete(): bool + { + return $this->getPrimaryStripePlatform() !== null; + } + + public function getOrganizerVatSetting(): ?OrganizerVatSettingDomainObject + { + return $this->vatSetting; + } + + public function setOrganizerVatSetting(?OrganizerVatSettingDomainObject $vatSetting): self + { + $this->vatSetting = $vatSetting; + + return $this; + } + + public function getOrganizerConfiguration(): ?OrganizerConfigurationDomainObject + { + return $this->configuration; + } + + public function setOrganizerConfiguration(?OrganizerConfigurationDomainObject $configuration): self + { + $this->configuration = $configuration; + + return $this; + } } diff --git a/backend/app/DomainObjects/OrganizerStripePlatformDomainObject.php b/backend/app/DomainObjects/OrganizerStripePlatformDomainObject.php new file mode 100644 index 0000000000..20df53404e --- /dev/null +++ b/backend/app/DomainObjects/OrganizerStripePlatformDomainObject.php @@ -0,0 +1,7 @@ +order = $order; + return $this; } @@ -65,6 +70,7 @@ public function getOrder(): ?OrderDomainObject public function setProductPrice(?ProductPriceDomainObject $productPrice): self { $this->productPrice = $productPrice; + return $this; } @@ -73,4 +79,15 @@ public function getProductPrice(): ?ProductPriceDomainObject return $this->productPrice; } + public function setEventOccurrence(?EventOccurrenceDomainObject $eventOccurrence): self + { + $this->eventOccurrence = $eventOccurrence; + + return $this; + } + + public function getEventOccurrence(): ?EventOccurrenceDomainObject + { + return $this->eventOccurrence; + } } diff --git a/backend/app/Events/CapacityChangedEvent.php b/backend/app/Events/CapacityChangedEvent.php index f0894827a0..c0ff639f2f 100644 --- a/backend/app/Events/CapacityChangedEvent.php +++ b/backend/app/Events/CapacityChangedEvent.php @@ -12,6 +12,7 @@ public function __construct( public ?int $productId = null, public ?int $productPriceId = null, public ?int $newCapacity = null, + public ?int $eventOccurrenceId = null, ) { } diff --git a/backend/app/Events/OccurrenceCancelledEvent.php b/backend/app/Events/OccurrenceCancelledEvent.php new file mode 100644 index 0000000000..1fd4b0c5ef --- /dev/null +++ b/backend/app/Events/OccurrenceCancelledEvent.php @@ -0,0 +1,18 @@ +join(', ') : ''; + $occurrenceDate = $attendee->getEventOccurrence()?->getStartDate() + ? Carbon::parse($attendee->getEventOccurrence()->getStartDate())->format('Y-m-d H:i:s') + : ''; + return array_merge([ $attendee->getId(), $attendee->getFirstName(), @@ -129,6 +134,7 @@ public function map($attendee): array $attendee->getProductId(), $ticketName, $attendee->getEventId(), + $occurrenceDate, $attendee->getPublicId(), $attendee->getShortId(), Carbon::parse($attendee->getCreatedAt())->format('Y-m-d H:i:s'), diff --git a/backend/app/Exports/OrdersExport.php b/backend/app/Exports/OrdersExport.php index 71ac6e1948..3c38b4da08 100644 --- a/backend/app/Exports/OrdersExport.php +++ b/backend/app/Exports/OrdersExport.php @@ -4,7 +4,9 @@ use Carbon\Carbon; use HiEvents\DomainObjects\Enums\QuestionTypeEnum; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\OrderDomainObject; +use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\QuestionDomainObject; use HiEvents\Resources\Order\OrderResource; use HiEvents\Services\Domain\Question\QuestionAnswerFormatter; @@ -20,16 +22,16 @@ class OrdersExport implements FromCollection, WithHeadings, WithMapping, WithStyles { private LengthAwarePaginator $orders; + private Collection $questions; - public function __construct(private QuestionAnswerFormatter $questionAnswerFormatter) - { - } + public function __construct(private QuestionAnswerFormatter $questionAnswerFormatter) {} public function withData(LengthAwarePaginator $orders, Collection $questions): OrdersExport { $this->orders = $orders; $this->questions = $questions; + return $this; } @@ -40,7 +42,7 @@ public function collection(): AnonymousResourceCollection public function headings(): array { - $questionTitles = $this->questions->map(fn($question) => $question->getTitle())->toArray(); + $questionTitles = $this->questions->map(fn ($question) => $question->getTitle())->toArray(); return array_merge([ __('ID'), @@ -58,6 +60,7 @@ public function headings(): array __('Currency'), __('Created At'), __('Public ID'), + __('Occurrence Date'), __('Payment Provider'), __('Is Partially Refunded'), __('Is Fully Refunded'), @@ -71,14 +74,13 @@ public function headings(): array } /** - * @param OrderDomainObject $order - * @return array + * @param OrderDomainObject $order */ public function map($order): array { $answers = $this->questions->map(function (QuestionDomainObject $question) use ($order) { $answer = $order->getQuestionAndAnswerViews() - ->first(fn($qav) => $qav->getQuestionId() === $question->getId())?->getAnswer() ?? ''; + ->first(fn ($qav) => $qav->getQuestionId() === $question->getId())?->getAnswer() ?? ''; return $this->questionAnswerFormatter->getAnswerAsText( $answer, @@ -86,6 +88,16 @@ public function map($order): array ); }); + // Orders can span multiple occurrences (series passes). List every distinct + // occurrence date, not just the first, so the export doesn't silently lose data. + $occurrenceDate = $order->getOrderItems() + ?->map(fn (OrderItemDomainObject $item) => $item->getEventOccurrence()) + ?->filter() + ?->unique(fn (EventOccurrenceDomainObject $occ) => $occ->getId()) + ?->sortBy(fn (EventOccurrenceDomainObject $occ) => $occ->getStartDate()) + ?->map(fn (EventOccurrenceDomainObject $occ) => Carbon::parse($occ->getStartDate())->format('Y-m-d H:i:s')) + ?->implode(', ') ?? ''; + return array_merge([ $order->getId(), $order->getFirstName(), @@ -102,6 +114,7 @@ public function map($order): array $order->getCurrency(), Carbon::parse($order->getCreatedAt())->format('Y-m-d H:i:s'), $order->getPublicId(), + $occurrenceDate, $order->getPaymentProvider(), $order->isPartiallyRefunded(), $order->isFullyRefunded(), diff --git a/backend/app/Helper/IdHelper.php b/backend/app/Helper/IdHelper.php index 5a124effb8..107002df21 100644 --- a/backend/app/Helper/IdHelper.php +++ b/backend/app/Helper/IdHelper.php @@ -13,6 +13,7 @@ class IdHelper public const CHECK_IN_LIST_PREFIX = 'cil'; public const CHECK_IN_PREFIX = 'ci'; + public const OCCURRENCE_PREFIX = 'oc'; public static function shortId(string $prefix, int $length = 13): string { diff --git a/backend/app/Http/Actions/Accounts/GetAccountAction.php b/backend/app/Http/Actions/Accounts/GetAccountAction.php index 2bf9bf6686..26f0280632 100644 --- a/backend/app/Http/Actions/Accounts/GetAccountAction.php +++ b/backend/app/Http/Actions/Accounts/GetAccountAction.php @@ -4,11 +4,8 @@ namespace HiEvents\Http\Actions\Accounts; -use HiEvents\DomainObjects\AccountConfigurationDomainObject; -use HiEvents\DomainObjects\AccountStripePlatformDomainObject; use HiEvents\DomainObjects\Enums\Role; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\AccountRepositoryInterface; use HiEvents\Resources\Account\AccountResource; use Illuminate\Http\JsonResponse; @@ -26,13 +23,7 @@ public function __invoke(?int $accountId = null): JsonResponse { $this->minimumAllowedRole(Role::ORGANIZER); - $account = $this->accountRepository - ->loadRelation(new Relationship( - domainObject: AccountConfigurationDomainObject::class, - name: 'configuration', - )) - ->loadRelation(AccountStripePlatformDomainObject::class) - ->findById($this->getAuthenticatedAccountId()); + $account = $this->accountRepository->findById($this->getAuthenticatedAccountId()); return $this->resourceResponse(AccountResource::class, $account); } diff --git a/backend/app/Http/Actions/Accounts/Stripe/GetStripeConnectAccountsAction.php b/backend/app/Http/Actions/Accounts/Stripe/GetStripeConnectAccountsAction.php deleted file mode 100644 index e1f607d926..0000000000 --- a/backend/app/Http/Actions/Accounts/Stripe/GetStripeConnectAccountsAction.php +++ /dev/null @@ -1,34 +0,0 @@ -isActionAuthorized($accountId, AccountDomainObject::class, Role::ADMIN); - - $result = $this->getStripeConnectAccountsHandler->handle($accountId); - - return $this->resourceResponse( - resource: StripeConnectAccountsResponseResource::class, - data: $result, - ); - } -} diff --git a/backend/app/Http/Actions/Accounts/Vat/GetAccountVatSettingAction.php b/backend/app/Http/Actions/Accounts/Vat/GetAccountVatSettingAction.php deleted file mode 100644 index 18e693c171..0000000000 --- a/backend/app/Http/Actions/Accounts/Vat/GetAccountVatSettingAction.php +++ /dev/null @@ -1,36 +0,0 @@ -minimumAllowedRole(Role::ORGANIZER); - - if ($accountId !== $this->getAuthenticatedAccountId()) { - return $this->errorResponse(__('Unauthorized')); - } - - $vatSetting = $this->handler->handle($accountId); - - if (!$vatSetting) { - return $this->jsonResponse(['data' => null]); - } - - return $this->resourceResponse(AccountVatSettingResource::class, $vatSetting); - } -} diff --git a/backend/app/Http/Actions/Accounts/Vat/UpsertAccountVatSettingAction.php b/backend/app/Http/Actions/Accounts/Vat/UpsertAccountVatSettingAction.php deleted file mode 100644 index 63280b6958..0000000000 --- a/backend/app/Http/Actions/Accounts/Vat/UpsertAccountVatSettingAction.php +++ /dev/null @@ -1,43 +0,0 @@ -minimumAllowedRole(Role::ADMIN); - - if ($accountId !== $this->getAuthenticatedAccountId()) { - return $this->errorResponse(__('Unauthorized')); - } - - $validated = $request->validate([ - 'vat_registered' => 'required|boolean', - 'vat_number' => 'nullable|string|max:20', - ]); - - $vatSetting = $this->handler->handle(new UpsertAccountVatSettingDTO( - accountId: $accountId, - vatRegistered: $validated['vat_registered'], - vatNumber: $validated['vat_number'] ?? null, - )); - - return $this->resourceResponse(AccountVatSettingResource::class, $vatSetting); - } -} diff --git a/backend/app/Http/Actions/Admin/Accounts/AssignConfigurationAction.php b/backend/app/Http/Actions/Admin/Accounts/AssignConfigurationAction.php deleted file mode 100644 index 73b0be38b1..0000000000 --- a/backend/app/Http/Actions/Admin/Accounts/AssignConfigurationAction.php +++ /dev/null @@ -1,34 +0,0 @@ -minimumAllowedRole(Role::SUPERADMIN); - - $validated = $request->validate([ - 'configuration_id' => 'required|integer|exists:account_configuration,id', - ]); - - $this->handler->handle($accountId, (int) $validated['configuration_id']); - - return $this->jsonResponse([ - 'message' => __('Configuration assigned successfully.'), - ]); - } -} diff --git a/backend/app/Http/Actions/Admin/Accounts/UpdateAccountConfigurationAction.php b/backend/app/Http/Actions/Admin/Accounts/UpdateAccountConfigurationAction.php deleted file mode 100644 index c7fe7d6095..0000000000 --- a/backend/app/Http/Actions/Admin/Accounts/UpdateAccountConfigurationAction.php +++ /dev/null @@ -1,43 +0,0 @@ -minimumAllowedRole(Role::SUPERADMIN); - - $validated = $request->validate([ - 'application_fees' => 'required|array', - 'application_fees.fixed' => 'required|numeric|min:0', - 'application_fees.percentage' => 'required|numeric|min:0|max:100', - ]); - - $configuration = $this->handler->handle(new UpdateAccountConfigurationDTO( - accountId: $accountId, - applicationFees: $validated['application_fees'], - )); - - return $this->resourceResponse( - resource: AccountConfigurationResource::class, - data: $configuration - ); - } -} diff --git a/backend/app/Http/Actions/Admin/Configurations/CreateConfigurationAction.php b/backend/app/Http/Actions/Admin/Configurations/CreateConfigurationAction.php index 6babb80c19..3c2364452e 100644 --- a/backend/app/Http/Actions/Admin/Configurations/CreateConfigurationAction.php +++ b/backend/app/Http/Actions/Admin/Configurations/CreateConfigurationAction.php @@ -6,8 +6,8 @@ use HiEvents\DomainObjects\Enums\Role; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Repository\Interfaces\AccountConfigurationRepositoryInterface; -use HiEvents\Resources\Account\AccountConfigurationResource; +use HiEvents\Repository\Interfaces\OrganizerConfigurationRepositoryInterface; +use HiEvents\Resources\Organizer\OrganizerConfigurationResource; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; @@ -15,7 +15,7 @@ class CreateConfigurationAction extends BaseAction { public function __construct( - private readonly AccountConfigurationRepositoryInterface $repository, + private readonly OrganizerConfigurationRepositoryInterface $repository, ) { } @@ -40,7 +40,7 @@ public function __invoke(Request $request): JsonResponse ]); return $this->jsonResponse( - new AccountConfigurationResource($configuration), + new OrganizerConfigurationResource($configuration), statusCode: Response::HTTP_CREATED, wrapInData: true ); diff --git a/backend/app/Http/Actions/Admin/Configurations/GetAllConfigurationsAction.php b/backend/app/Http/Actions/Admin/Configurations/GetAllConfigurationsAction.php index 0ffcf213eb..c9581753c6 100644 --- a/backend/app/Http/Actions/Admin/Configurations/GetAllConfigurationsAction.php +++ b/backend/app/Http/Actions/Admin/Configurations/GetAllConfigurationsAction.php @@ -6,14 +6,14 @@ use HiEvents\DomainObjects\Enums\Role; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Repository\Interfaces\AccountConfigurationRepositoryInterface; -use HiEvents\Resources\Account\AccountConfigurationResource; +use HiEvents\Repository\Interfaces\OrganizerConfigurationRepositoryInterface; +use HiEvents\Resources\Organizer\OrganizerConfigurationResource; use Illuminate\Http\JsonResponse; class GetAllConfigurationsAction extends BaseAction { public function __construct( - private readonly AccountConfigurationRepositoryInterface $repository, + private readonly OrganizerConfigurationRepositoryInterface $repository, ) { } @@ -24,7 +24,7 @@ public function __invoke(): JsonResponse $configurations = $this->repository->all(); return $this->jsonResponse( - AccountConfigurationResource::collection($configurations), + OrganizerConfigurationResource::collection($configurations), wrapInData: true ); } diff --git a/backend/app/Http/Actions/Admin/Configurations/UpdateConfigurationAction.php b/backend/app/Http/Actions/Admin/Configurations/UpdateConfigurationAction.php index 947a152c23..0af68f0022 100644 --- a/backend/app/Http/Actions/Admin/Configurations/UpdateConfigurationAction.php +++ b/backend/app/Http/Actions/Admin/Configurations/UpdateConfigurationAction.php @@ -6,15 +6,15 @@ use HiEvents\DomainObjects\Enums\Role; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Repository\Interfaces\AccountConfigurationRepositoryInterface; -use HiEvents\Resources\Account\AccountConfigurationResource; +use HiEvents\Repository\Interfaces\OrganizerConfigurationRepositoryInterface; +use HiEvents\Resources\Organizer\OrganizerConfigurationResource; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class UpdateConfigurationAction extends BaseAction { public function __construct( - private readonly AccountConfigurationRepositoryInterface $repository, + private readonly OrganizerConfigurationRepositoryInterface $repository, ) { } @@ -41,7 +41,7 @@ public function __invoke(Request $request, int $configurationId): JsonResponse ); return $this->jsonResponse( - new AccountConfigurationResource($configuration), + new OrganizerConfigurationResource($configuration), wrapInData: true ); } diff --git a/backend/app/Http/Actions/Admin/Organizers/AssignOrganizerConfigurationAction.php b/backend/app/Http/Actions/Admin/Organizers/AssignOrganizerConfigurationAction.php new file mode 100644 index 0000000000..667e61c823 --- /dev/null +++ b/backend/app/Http/Actions/Admin/Organizers/AssignOrganizerConfigurationAction.php @@ -0,0 +1,35 @@ +minimumAllowedRole(Role::SUPERADMIN); + + $validated = $request->validate([ + 'configuration_id' => 'required|integer|exists:organizer_configurations,id', + ]); + + $this->handler->handle($organizerId, (int)$validated['configuration_id']); + + return $this->jsonResponse([ + 'message' => __('Configuration assigned successfully.'), + ]); + } +} diff --git a/backend/app/Http/Actions/Admin/Organizers/UpdateOrganizerConfigurationAction.php b/backend/app/Http/Actions/Admin/Organizers/UpdateOrganizerConfigurationAction.php new file mode 100644 index 0000000000..f7c8335c06 --- /dev/null +++ b/backend/app/Http/Actions/Admin/Organizers/UpdateOrganizerConfigurationAction.php @@ -0,0 +1,44 @@ +minimumAllowedRole(Role::SUPERADMIN); + + $validated = $request->validate([ + 'application_fees' => 'required|array', + 'application_fees.fixed' => 'required|numeric|min:0', + 'application_fees.percentage' => 'required|numeric|min:0|max:100', + 'application_fees.currency' => 'nullable|string|size:3', + ]); + + $configuration = $this->handler->handle(new UpdateOrganizerConfigurationDTO( + organizerId: $organizerId, + applicationFees: $validated['application_fees'], + )); + + return $this->resourceResponse( + resource: OrganizerConfigurationResource::class, + data: $configuration, + ); + } +} diff --git a/backend/app/Http/Actions/Admin/Accounts/UpdateAccountVatSettingAction.php b/backend/app/Http/Actions/Admin/Organizers/UpdateOrganizerVatSettingAction.php similarity index 61% rename from backend/app/Http/Actions/Admin/Accounts/UpdateAccountVatSettingAction.php rename to backend/app/Http/Actions/Admin/Organizers/UpdateOrganizerVatSettingAction.php index 030d758712..2445f5ede1 100644 --- a/backend/app/Http/Actions/Admin/Accounts/UpdateAccountVatSettingAction.php +++ b/backend/app/Http/Actions/Admin/Organizers/UpdateOrganizerVatSettingAction.php @@ -2,25 +2,25 @@ declare(strict_types=1); -namespace HiEvents\Http\Actions\Admin\Accounts; +namespace HiEvents\Http\Actions\Admin\Organizers; -use HiEvents\DataTransferObjects\UpdateAdminAccountVatSettingDTO; +use HiEvents\DataTransferObjects\UpdateAdminOrganizerVatSettingDTO; use HiEvents\DomainObjects\Enums\Role; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Resources\Account\AccountVatSettingResource; -use HiEvents\Services\Application\Handlers\Admin\UpdateAdminAccountVatSettingHandler; +use HiEvents\Resources\Organizer\Vat\OrganizerVatSettingResource; +use HiEvents\Services\Application\Handlers\Admin\Organizer\UpdateAdminOrganizerVatSettingHandler; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -class UpdateAccountVatSettingAction extends BaseAction +class UpdateOrganizerVatSettingAction extends BaseAction { public function __construct( - private readonly UpdateAdminAccountVatSettingHandler $handler, + private readonly UpdateAdminOrganizerVatSettingHandler $handler, ) { } - public function __invoke(Request $request, int $accountId): JsonResponse + public function __invoke(Request $request, int $organizerId): JsonResponse { $this->minimumAllowedRole(Role::SUPERADMIN); @@ -33,8 +33,8 @@ public function __invoke(Request $request, int $accountId): JsonResponse 'vat_country_code' => 'nullable|string|max:2', ]); - $vatSetting = $this->handler->handle(new UpdateAdminAccountVatSettingDTO( - accountId: $accountId, + $vatSetting = $this->handler->handle(new UpdateAdminOrganizerVatSettingDTO( + organizerId: $organizerId, vatRegistered: $validated['vat_registered'], vatNumber: $validated['vat_number'] ?? null, vatValidated: $validated['vat_validated'] ?? null, @@ -44,8 +44,8 @@ public function __invoke(Request $request, int $accountId): JsonResponse )); return $this->resourceResponse( - resource: AccountVatSettingResource::class, - data: $vatSetting + resource: OrganizerVatSettingResource::class, + data: $vatSetting, ); } } diff --git a/backend/app/Http/Actions/Attendees/CreateAttendeeAction.php b/backend/app/Http/Actions/Attendees/CreateAttendeeAction.php index 492b065108..6670131d4b 100644 --- a/backend/app/Http/Actions/Attendees/CreateAttendeeAction.php +++ b/backend/app/Http/Actions/Attendees/CreateAttendeeAction.php @@ -33,8 +33,11 @@ public function __invoke(CreateAttendeeRequest $request, int $eventId): JsonResp try { $attendee = $this->createAttendeeHandler->handle(CreateAttendeeDTO::fromArray( - array_merge($request->validationData(), [ + array_merge($request->validated(), [ 'event_id' => $eventId, + 'override_capacity' => (bool) $request->validated('override_capacity', false), + 'client_ip' => $this->getClientIp($request), + 'client_user_agent' => $request->userAgent(), ]) )); } catch (NoTicketsAvailableException $exception) { diff --git a/backend/app/Http/Actions/Attendees/ExportAttendeesAction.php b/backend/app/Http/Actions/Attendees/ExportAttendeesAction.php index dce25e228a..323f9baeed 100644 --- a/backend/app/Http/Actions/Attendees/ExportAttendeesAction.php +++ b/backend/app/Http/Actions/Attendees/ExportAttendeesAction.php @@ -6,6 +6,7 @@ use HiEvents\DomainObjects\CheckInListDomainObject; use HiEvents\DomainObjects\Enums\QuestionBelongsTo; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; @@ -15,6 +16,7 @@ use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\QuestionRepositoryInterface; +use Illuminate\Http\Request; use Maatwebsite\Excel\Facades\Excel; use Symfony\Component\HttpFoundation\BinaryFileResponse; @@ -31,10 +33,12 @@ public function __construct( /** * @todo This should be passed off to a queue and moved to a service */ - public function __invoke(int $eventId): BinaryFileResponse + public function __invoke(Request $request, int $eventId): BinaryFileResponse { $this->isActionAuthorized($eventId, EventDomainObject::class); + $eventOccurrenceId = $request->input('event_occurrence_id') ? (int) $request->input('event_occurrence_id') : null; + $attendees = $this->attendeeRepository ->loadRelation(QuestionAndAnswerViewDomainObject::class) ->loadRelation(new Relationship( @@ -65,7 +69,11 @@ public function __invoke(int $eventId): BinaryFileResponse ], name: 'order' )) - ->findByEventIdForExport($eventId); + ->loadRelation(new Relationship( + domainObject: EventOccurrenceDomainObject::class, + name: 'event_occurrence', + )) + ->findByEventIdForExport($eventId, $eventOccurrenceId); $productQuestions = $this->questionRepository->findWhere([ 'event_id' => $eventId, diff --git a/backend/app/Http/Actions/Attendees/GetAttendeeActionPublic.php b/backend/app/Http/Actions/Attendees/GetAttendeeActionPublic.php index 5be7ddf396..390c92eedd 100644 --- a/backend/app/Http/Actions/Attendees/GetAttendeeActionPublic.php +++ b/backend/app/Http/Actions/Attendees/GetAttendeeActionPublic.php @@ -2,6 +2,7 @@ namespace HiEvents\Http\Actions\Attendees; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\Generated\AttendeeDomainObjectAbstract; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; @@ -34,6 +35,10 @@ public function __invoke(int $eventId, string $attendeeShortId): JsonResponse|Re domainObject: ProductPriceDomainObject::class, ), ], name: 'product')) + ->loadRelation(new Relationship( + domainObject: EventOccurrenceDomainObject::class, + name: 'event_occurrence', + )) ->findFirstWhere([ AttendeeDomainObjectAbstract::SHORT_ID => $attendeeShortId ]); diff --git a/backend/app/Http/Actions/CheckInLists/CreateCheckInListAction.php b/backend/app/Http/Actions/CheckInLists/CreateCheckInListAction.php index c9dc08d3ed..a0ad820dec 100644 --- a/backend/app/Http/Actions/CheckInLists/CreateCheckInListAction.php +++ b/backend/app/Http/Actions/CheckInLists/CreateCheckInListAction.php @@ -30,9 +30,13 @@ public function __invoke(UpsertCheckInListRequest $request, int $eventId): JsonR name: $request->validated('name'), description: $request->validated('description'), eventId: $eventId, - productIds: $request->validated('product_ids'), + productIds: $request->validated('product_ids') ?? [], expiresAt: $request->validated('expires_at'), activatesAt: $request->validated('activates_at'), + eventOccurrenceId: $request->validated('event_occurrence_id'), + publicShowAttendeeNotes: $request->validated('public_show_attendee_notes') ?? true, + publicShowQuestionAnswers: $request->validated('public_show_question_answers') ?? true, + publicShowOrderDetails: $request->validated('public_show_order_details') ?? true, ) ); } catch (UnrecognizedProductIdException $exception) { diff --git a/backend/app/Http/Actions/CheckInLists/DeleteCheckInListAction.php b/backend/app/Http/Actions/CheckInLists/DeleteCheckInListAction.php index f707a0737a..2b0171d027 100644 --- a/backend/app/Http/Actions/CheckInLists/DeleteCheckInListAction.php +++ b/backend/app/Http/Actions/CheckInLists/DeleteCheckInListAction.php @@ -3,8 +3,10 @@ namespace HiEvents\Http\Actions\CheckInLists; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\Exceptions\ResourceConflictException; use HiEvents\Http\Actions\BaseAction; use HiEvents\Services\Application\Handlers\CheckInList\DeleteCheckInListHandler; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; class DeleteCheckInListAction extends BaseAction @@ -15,14 +17,21 @@ public function __construct( { } - public function __invoke(int $eventId, int $checkInListId): Response + public function __invoke(int $eventId, int $checkInListId): Response|JsonResponse { $this->isActionAuthorized($eventId, EventDomainObject::class); - $this->deleteCheckInListHandler->handle( - eventId: $eventId, - checkInListId: $checkInListId, - ); + try { + $this->deleteCheckInListHandler->handle( + eventId: $eventId, + checkInListId: $checkInListId, + ); + } catch (ResourceConflictException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: Response::HTTP_CONFLICT, + ); + } return $this->noContentResponse(); } diff --git a/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeeDetailPublicAction.php b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeeDetailPublicAction.php new file mode 100644 index 0000000000..f20add554a --- /dev/null +++ b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeeDetailPublicAction.php @@ -0,0 +1,57 @@ +handler->handle( + shortId: $checkInListShortId, + attendeePublicId: $attendeePublicId, + staffAccountId: $this->resolveStaffAccountId(), + ); + } catch (CannotCheckInException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: Response::HTTP_FORBIDDEN, + ); + } + + return $this->resourceResponse( + resource: AttendeeDetailPublicResource::class, + data: $detail, + ); + } + + /** + * The detail endpoint is public but should reveal all attendee fields to authenticated staff + * whose account matches the event's account. Returns null for anonymous / invalid tokens / + * any auth resolution failure — those callers get data filtered by the list's visibility flags. + */ + private function resolveStaffAccountId(): ?int + { + try { + return $this->authUserService->getAuthenticatedAccountId(); + } catch (Throwable) { + return null; + } + } +} diff --git a/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListStatsPublicAction.php b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListStatsPublicAction.php new file mode 100644 index 0000000000..43466a9479 --- /dev/null +++ b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListStatsPublicAction.php @@ -0,0 +1,32 @@ +query('event_occurrence_id'); + $occurrenceIdInt = is_numeric($occurrenceId) ? (int) $occurrenceId : null; + + $stats = $this->getCheckInListStatsPublicHandler->handle($checkInListShortId, $occurrenceIdInt); + + return $this->resourceResponse( + resource: CheckInListStatsPublicResource::class, + data: $stats, + ); + } +} diff --git a/backend/app/Http/Actions/CheckInLists/UpdateCheckInListAction.php b/backend/app/Http/Actions/CheckInLists/UpdateCheckInListAction.php index dceda8c893..0dcf666957 100644 --- a/backend/app/Http/Actions/CheckInLists/UpdateCheckInListAction.php +++ b/backend/app/Http/Actions/CheckInLists/UpdateCheckInListAction.php @@ -30,10 +30,14 @@ public function __invoke(UpsertCheckInListRequest $request, int $eventId, int $c name: $request->validated('name'), description: $request->validated('description'), eventId: $eventId, - productIds: $request->validated('product_ids'), + productIds: $request->validated('product_ids') ?? [], expiresAt: $request->validated('expires_at'), activatesAt: $request->validated('activates_at'), id: $checkInListId, + eventOccurrenceId: $request->validated('event_occurrence_id'), + publicShowAttendeeNotes: $request->validated('public_show_attendee_notes') ?? true, + publicShowQuestionAnswers: $request->validated('public_show_question_answers') ?? true, + publicShowOrderDetails: $request->validated('public_show_order_details') ?? true, ) ); } catch (UnrecognizedProductIdException $exception) { diff --git a/backend/app/Http/Actions/EmailTemplates/BaseEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/BaseEmailTemplateAction.php index c8648519fa..a39ed519f2 100644 --- a/backend/app/Http/Actions/EmailTemplates/BaseEmailTemplateAction.php +++ b/backend/app/Http/Actions/EmailTemplates/BaseEmailTemplateAction.php @@ -82,7 +82,7 @@ protected function handlePreviewRequest(Request $request, PreviewEmailTemplateHa $cta = [ 'label' => $validated['ctaLabel'], - 'url_token' => $validated['template_type'] === 'order_confirmation' ? 'order.url' : 'ticket.url', + 'url_token' => EmailTemplateType::from($validated['template_type'])->ctaUrlToken(), ]; $preview = $handler->handle( diff --git a/backend/app/Http/Actions/EmailTemplates/CreateEventEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/CreateEventEmailTemplateAction.php index 8b52507ede..bb60bccebd 100644 --- a/backend/app/Http/Actions/EmailTemplates/CreateEventEmailTemplateAction.php +++ b/backend/app/Http/Actions/EmailTemplates/CreateEventEmailTemplateAction.php @@ -42,7 +42,7 @@ public function __invoke(Request $request, int $eventId): JsonResponse try { $cta = [ 'label' => $validated['ctaLabel'], - 'url_token' => $validated['template_type'] === 'order_confirmation' ? 'order.url' : 'ticket.url', + 'url_token' => EmailTemplateType::from($validated['template_type'])->ctaUrlToken(), ]; $template = $this->handler->handle( diff --git a/backend/app/Http/Actions/EmailTemplates/CreateOrganizerEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/CreateOrganizerEmailTemplateAction.php index c0e4411182..46bc1dd7ae 100644 --- a/backend/app/Http/Actions/EmailTemplates/CreateOrganizerEmailTemplateAction.php +++ b/backend/app/Http/Actions/EmailTemplates/CreateOrganizerEmailTemplateAction.php @@ -42,7 +42,7 @@ public function __invoke(Request $request, int $organizerId): JsonResponse try { $cta = [ 'label' => $validated['ctaLabel'], - 'url_token' => $validated['template_type'] === 'order_confirmation' ? 'order.url' : 'ticket.url', + 'url_token' => EmailTemplateType::from($validated['template_type'])->ctaUrlToken(), ]; $template = $this->handler->handle( diff --git a/backend/app/Http/Actions/EmailTemplates/UpdateEventEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/UpdateEventEmailTemplateAction.php index f7be0096e3..a7b1e30e48 100644 --- a/backend/app/Http/Actions/EmailTemplates/UpdateEventEmailTemplateAction.php +++ b/backend/app/Http/Actions/EmailTemplates/UpdateEventEmailTemplateAction.php @@ -10,6 +10,7 @@ use HiEvents\Exceptions\InvalidEmailTemplateException; use HiEvents\Http\Resources\EmailTemplateResource; use HiEvents\Http\ResponseCodes; +use HiEvents\Repository\Interfaces\EmailTemplateRepositoryInterface; use HiEvents\Services\Application\Handlers\EmailTemplate\DTO\UpsertEmailTemplateDTO; use HiEvents\Services\Application\Handlers\EmailTemplate\UpdateEmailTemplateHandler; use Illuminate\Http\JsonResponse; @@ -20,7 +21,8 @@ class UpdateEventEmailTemplateAction extends BaseEmailTemplateAction { public function __construct( - private readonly UpdateEmailTemplateHandler $handler + private readonly UpdateEmailTemplateHandler $handler, + private readonly EmailTemplateRepositoryInterface $emailTemplateRepository, ) { } @@ -41,15 +43,18 @@ public function __invoke(Request $request, int $eventId, int $templateId): JsonR $validated = $this->validateUpdateEmailTemplateRequest($request); try { + $existingTemplate = $this->emailTemplateRepository->findById($templateId); + $templateType = EmailTemplateType::from($existingTemplate->getTemplateType()); + $cta = [ 'label' => $validated['ctaLabel'], - 'url_token' => 'order.url', // This will be determined by template type during update + 'url_token' => $templateType->ctaUrlToken(), ]; - + $template = $this->handler->handle( new UpsertEmailTemplateDTO( account_id: $this->getAuthenticatedAccountId(), - template_type: EmailTemplateType::ORDER_CONFIRMATION, // This will be ignored in update + template_type: $templateType, subject: $validated['subject'], body: $validated['body'], organizer_id: null, diff --git a/backend/app/Http/Actions/EmailTemplates/UpdateOrganizerEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/UpdateOrganizerEmailTemplateAction.php index 13620b45a8..1ad128ca13 100644 --- a/backend/app/Http/Actions/EmailTemplates/UpdateOrganizerEmailTemplateAction.php +++ b/backend/app/Http/Actions/EmailTemplates/UpdateOrganizerEmailTemplateAction.php @@ -10,6 +10,7 @@ use HiEvents\Exceptions\InvalidEmailTemplateException; use HiEvents\Http\Resources\EmailTemplateResource; use HiEvents\Http\ResponseCodes; +use HiEvents\Repository\Interfaces\EmailTemplateRepositoryInterface; use HiEvents\Services\Application\Handlers\EmailTemplate\UpdateEmailTemplateHandler; use HiEvents\Services\Application\Handlers\EmailTemplate\DTO\UpsertEmailTemplateDTO; use Illuminate\Http\JsonResponse; @@ -20,7 +21,8 @@ class UpdateOrganizerEmailTemplateAction extends BaseEmailTemplateAction { public function __construct( - private readonly UpdateEmailTemplateHandler $handler + private readonly UpdateEmailTemplateHandler $handler, + private readonly EmailTemplateRepositoryInterface $emailTemplateRepository, ) { } @@ -40,15 +42,18 @@ public function __invoke(Request $request, int $organizerId, int $templateId): J $validated = $this->validateUpdateEmailTemplateRequest($request); try { + $existingTemplate = $this->emailTemplateRepository->findById($templateId); + $templateType = EmailTemplateType::from($existingTemplate->getTemplateType()); + $cta = [ 'label' => $validated['ctaLabel'], - 'url_token' => 'order.url', // This will be determined by template type during update + 'url_token' => $templateType->ctaUrlToken(), ]; - + $template = $this->handler->handle( new UpsertEmailTemplateDTO( account_id: $this->getAuthenticatedAccountId(), - template_type: EmailTemplateType::ORDER_CONFIRMATION, // This will be ignored in update + template_type: $templateType, subject: $validated['subject'], body: $validated['body'], organizer_id: $organizerId, diff --git a/backend/app/Http/Actions/EventOccurrences/BulkUpdateOccurrencesAction.php b/backend/app/Http/Actions/EventOccurrences/BulkUpdateOccurrencesAction.php new file mode 100644 index 0000000000..ac08870f81 --- /dev/null +++ b/backend/app/Http/Actions/EventOccurrences/BulkUpdateOccurrencesAction.php @@ -0,0 +1,58 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $event = $this->eventRepository->findById($eventId); + + $result = $this->handler->handle( + new BulkUpdateOccurrencesDTO( + event_id: $eventId, + action: BulkOccurrenceAction::from($request->validated('action')), + timezone: $event->getTimezone(), + start_time_shift: $request->validated('start_time_shift') !== null + ? (int) $request->validated('start_time_shift') + : null, + end_time_shift: $request->validated('end_time_shift') !== null + ? (int) $request->validated('end_time_shift') + : null, + capacity: $request->validated('capacity') !== null ? (int) $request->validated('capacity') : null, + clear_capacity: (bool) $request->validated('clear_capacity', false), + future_only: (bool) $request->validated('future_only', true), + skip_overridden: (bool) $request->validated('skip_overridden', true), + refund_orders: (bool) $request->validated('refund_orders', false), + occurrence_ids: $request->validated('occurrence_ids'), + apply_to_all: (bool) $request->validated('apply_to_all', false), + label: $request->validated('label'), + clear_label: (bool) $request->validated('clear_label', false), + duration_minutes: $request->validated('duration_minutes') !== null + ? (int) $request->validated('duration_minutes') + : null, + ) + ); + + return $this->jsonResponse([ + 'updated_count' => $result->updated_count, + 'updated_ids' => $result->updated_ids, + ]); + } +} diff --git a/backend/app/Http/Actions/EventOccurrences/CancelOccurrenceAction.php b/backend/app/Http/Actions/EventOccurrences/CancelOccurrenceAction.php new file mode 100644 index 0000000000..17f3412d46 --- /dev/null +++ b/backend/app/Http/Actions/EventOccurrences/CancelOccurrenceAction.php @@ -0,0 +1,33 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $occurrence = $this->handler->handle( + eventId: $eventId, + occurrenceId: $occurrenceId, + refundOrders: (bool) $request->validated('refund_orders', false), + ); + + return $this->resourceResponse( + resource: EventOccurrenceResource::class, + data: $occurrence, + ); + } +} diff --git a/backend/app/Http/Actions/EventOccurrences/CreateEventOccurrenceAction.php b/backend/app/Http/Actions/EventOccurrences/CreateEventOccurrenceAction.php new file mode 100644 index 0000000000..9aec2e065b --- /dev/null +++ b/backend/app/Http/Actions/EventOccurrences/CreateEventOccurrenceAction.php @@ -0,0 +1,48 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $event = $this->eventRepository->findById($eventId); + $timezone = $event->getTimezone(); + + $startDate = $request->validated('start_date'); + $endDate = $request->validated('end_date'); + + $occurrence = $this->handler->handle( + new UpsertEventOccurrenceDTO( + event_id: $eventId, + start_date: DateHelper::convertToUTC($startDate, $timezone), + end_date: $endDate ? DateHelper::convertToUTC($endDate, $timezone) : null, + capacity: $request->validated('capacity'), + label: $request->validated('label'), + is_overridden: true, + ) + ); + + return $this->resourceResponse( + resource: EventOccurrenceResource::class, + data: $occurrence, + ); + } +} diff --git a/backend/app/Http/Actions/EventOccurrences/DeleteEventOccurrenceAction.php b/backend/app/Http/Actions/EventOccurrences/DeleteEventOccurrenceAction.php new file mode 100644 index 0000000000..b80cd03db2 --- /dev/null +++ b/backend/app/Http/Actions/EventOccurrences/DeleteEventOccurrenceAction.php @@ -0,0 +1,26 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $this->handler->handle($eventId, $occurrenceId); + + return $this->deletedResponse(); + } +} diff --git a/backend/app/Http/Actions/EventOccurrences/DeletePriceOverrideAction.php b/backend/app/Http/Actions/EventOccurrences/DeletePriceOverrideAction.php new file mode 100644 index 0000000000..f371e5eb4f --- /dev/null +++ b/backend/app/Http/Actions/EventOccurrences/DeletePriceOverrideAction.php @@ -0,0 +1,26 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $this->handler->handle($eventId, $occurrenceId, $overrideId); + + return $this->deletedResponse(); + } +} diff --git a/backend/app/Http/Actions/EventOccurrences/GenerateOccurrencesAction.php b/backend/app/Http/Actions/EventOccurrences/GenerateOccurrencesAction.php new file mode 100644 index 0000000000..0099f4aa6a --- /dev/null +++ b/backend/app/Http/Actions/EventOccurrences/GenerateOccurrencesAction.php @@ -0,0 +1,43 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + try { + $occurrences = $this->handler->handle( + new GenerateOccurrencesDTO( + event_id: $eventId, + recurrence_rule: $request->validated('recurrence_rule'), + ) + ); + } catch (InvalidRecurrenceRuleException $e) { + throw ValidationException::withMessages([ + 'recurrence_rule' => [$e->getMessage()], + ]); + } + + return $this->resourceResponse( + resource: EventOccurrenceResource::class, + data: $occurrences, + ); + } +} diff --git a/backend/app/Http/Actions/EventOccurrences/GetEventOccurrenceAction.php b/backend/app/Http/Actions/EventOccurrences/GetEventOccurrenceAction.php new file mode 100644 index 0000000000..9d280e31c1 --- /dev/null +++ b/backend/app/Http/Actions/EventOccurrences/GetEventOccurrenceAction.php @@ -0,0 +1,30 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $occurrence = $this->handler->handle($eventId, $occurrenceId); + + return $this->resourceResponse( + resource: EventOccurrenceResource::class, + data: $occurrence, + ); + } +} diff --git a/backend/app/Http/Actions/EventOccurrences/GetEventOccurrencesAction.php b/backend/app/Http/Actions/EventOccurrences/GetEventOccurrencesAction.php new file mode 100644 index 0000000000..322cbad145 --- /dev/null +++ b/backend/app/Http/Actions/EventOccurrences/GetEventOccurrencesAction.php @@ -0,0 +1,40 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + // include_stats=false skips the per-row statistics relation for selector + // use cases (occurrence dropdowns) where the stats payload is wasted. + $includeStats = $request->boolean('include_stats', true); + + $occurrences = $this->handler->handle( + $eventId, + QueryParamsDTO::fromArray($request->query->all()), + $includeStats, + ); + + return $this->filterableResourceResponse( + resource: EventOccurrenceResource::class, + data: $occurrences, + domainObject: EventOccurrenceDomainObject::class, + ); + } +} diff --git a/backend/app/Http/Actions/EventOccurrences/GetPriceOverridesAction.php b/backend/app/Http/Actions/EventOccurrences/GetPriceOverridesAction.php new file mode 100644 index 0000000000..c5c47cf323 --- /dev/null +++ b/backend/app/Http/Actions/EventOccurrences/GetPriceOverridesAction.php @@ -0,0 +1,30 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $overrides = $this->handler->handle($eventId, $occurrenceId); + + return $this->resourceResponse( + resource: ProductPriceOccurrenceOverrideResource::class, + data: $overrides, + ); + } +} diff --git a/backend/app/Http/Actions/EventOccurrences/GetProductVisibilityAction.php b/backend/app/Http/Actions/EventOccurrences/GetProductVisibilityAction.php new file mode 100644 index 0000000000..9d9c2762f4 --- /dev/null +++ b/backend/app/Http/Actions/EventOccurrences/GetProductVisibilityAction.php @@ -0,0 +1,30 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $visibility = $this->handler->handle($eventId, $occurrenceId); + + return $this->resourceResponse( + resource: ProductOccurrenceVisibilityResource::class, + data: $visibility, + ); + } +} diff --git a/backend/app/Http/Actions/EventOccurrences/ReactivateOccurrenceAction.php b/backend/app/Http/Actions/EventOccurrences/ReactivateOccurrenceAction.php new file mode 100644 index 0000000000..b5b902af8c --- /dev/null +++ b/backend/app/Http/Actions/EventOccurrences/ReactivateOccurrenceAction.php @@ -0,0 +1,32 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $occurrence = $this->handler->handle( + eventId: $eventId, + occurrenceId: $occurrenceId, + ); + + return $this->resourceResponse( + resource: EventOccurrenceResource::class, + data: $occurrence, + ); + } +} diff --git a/backend/app/Http/Actions/EventOccurrences/UpdateEventOccurrenceAction.php b/backend/app/Http/Actions/EventOccurrences/UpdateEventOccurrenceAction.php new file mode 100644 index 0000000000..4cef0195b5 --- /dev/null +++ b/backend/app/Http/Actions/EventOccurrences/UpdateEventOccurrenceAction.php @@ -0,0 +1,48 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $event = $this->eventRepository->findById($eventId); + $timezone = $event->getTimezone(); + + $startDate = $request->validated('start_date'); + $endDate = $request->validated('end_date'); + + $occurrence = $this->handler->handle( + $occurrenceId, + new UpsertEventOccurrenceDTO( + event_id: $eventId, + start_date: DateHelper::convertToUTC($startDate, $timezone), + end_date: $endDate ? DateHelper::convertToUTC($endDate, $timezone) : null, + capacity: $request->validated('capacity'), + label: $request->validated('label'), + ) + ); + + return $this->resourceResponse( + resource: EventOccurrenceResource::class, + data: $occurrence, + ); + } +} diff --git a/backend/app/Http/Actions/EventOccurrences/UpdateProductVisibilityAction.php b/backend/app/Http/Actions/EventOccurrences/UpdateProductVisibilityAction.php new file mode 100644 index 0000000000..eeffd8722f --- /dev/null +++ b/backend/app/Http/Actions/EventOccurrences/UpdateProductVisibilityAction.php @@ -0,0 +1,38 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $visibility = $this->handler->handle( + new UpdateProductVisibilityDTO( + event_id: $eventId, + event_occurrence_id: $occurrenceId, + product_ids: $request->validated('product_ids'), + ) + ); + + return $this->resourceResponse( + resource: ProductOccurrenceVisibilityResource::class, + data: $visibility, + ); + } +} diff --git a/backend/app/Http/Actions/EventOccurrences/UpsertPriceOverrideAction.php b/backend/app/Http/Actions/EventOccurrences/UpsertPriceOverrideAction.php new file mode 100644 index 0000000000..057b50d5bb --- /dev/null +++ b/backend/app/Http/Actions/EventOccurrences/UpsertPriceOverrideAction.php @@ -0,0 +1,39 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $override = $this->handler->handle( + new UpsertPriceOverrideDTO( + event_id: $eventId, + event_occurrence_id: $occurrenceId, + product_price_id: $request->validated('product_price_id'), + price: (float) $request->validated('price'), + ) + ); + + return $this->resourceResponse( + resource: ProductPriceOccurrenceOverrideResource::class, + data: $override, + ); + } +} diff --git a/backend/app/Http/Actions/Events/DuplicateEventAction.php b/backend/app/Http/Actions/Events/DuplicateEventAction.php index 160328fd27..5515a107af 100644 --- a/backend/app/Http/Actions/Events/DuplicateEventAction.php +++ b/backend/app/Http/Actions/Events/DuplicateEventAction.php @@ -39,6 +39,7 @@ public function __invoke(int $eventId, DuplicateEventRequest $request): JsonResp duplicateTicketLogo: $request->validated('duplicate_ticket_logo'), duplicateWebhooks: $request->validated('duplicate_webhooks'), duplicateAffiliates: $request->validated('duplicate_affiliates'), + duplicateOccurrences: $request->validated('duplicate_occurrences') ?? true, description: $request->validated('description'), endDate: $request->validated('end_date'), )); diff --git a/backend/app/Http/Actions/Events/GetEventAction.php b/backend/app/Http/Actions/Events/GetEventAction.php index 5df8dd1ccb..afbfcb5655 100644 --- a/backend/app/Http/Actions/Events/GetEventAction.php +++ b/backend/app/Http/Actions/Events/GetEventAction.php @@ -5,6 +5,7 @@ namespace HiEvents\Http\Actions\Events; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\ImageDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\ProductCategoryDomainObject; @@ -33,6 +34,7 @@ public function __invoke(int $eventId): JsonResponse $event = $this->eventRepository ->loadRelation(new Relationship(domainObject: OrganizerDomainObject::class, name: 'organizer')) ->loadRelation(new Relationship(ImageDomainObject::class)) + ->loadRelation(new Relationship(EventOccurrenceDomainObject::class)) ->loadRelation( new Relationship(ProductCategoryDomainObject::class, [ new Relationship(ProductDomainObject::class, [ diff --git a/backend/app/Http/Actions/Events/GetEventPublicAction.php b/backend/app/Http/Actions/Events/GetEventPublicAction.php index 6f909b2724..c920066e48 100644 --- a/backend/app/Http/Actions/Events/GetEventPublicAction.php +++ b/backend/app/Http/Actions/Events/GetEventPublicAction.php @@ -30,6 +30,7 @@ public function __invoke(int $eventId, Request $request): Response|JsonResponse 'ipAddress' => $this->getClientIp($request), 'promoCode' => strtolower($request->string('promo_code')), 'isAuthenticated' => $this->isUserAuthenticated(), + 'eventOccurrenceId' => $request->integer('event_occurrence_id') ?: null, ])); if (!$this->canUserViewEvent($event)) { diff --git a/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php b/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php index aa45ebb860..33f91ea0ba 100644 --- a/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php +++ b/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php @@ -23,10 +23,12 @@ public function __invoke(int $eventId, Request $request): JsonResponse $this->isActionAuthorized($eventId, EventDomainObject::class); $dateRangePreset = $request->query('date_range', 'month'); + $occurrenceIdQuery = $request->query('occurrence_id'); $stats = $this->eventStatsHandler->handle(EventStatsRequestDTO::fromArray([ 'event_id' => $eventId, 'date_range_preset' => $dateRangePreset, + 'occurrence_id' => $occurrenceIdQuery !== null ? (int)$occurrenceIdQuery : null, ])); return $this->resourceResponse(JsonResource::class, $stats); diff --git a/backend/app/Http/Actions/Messages/SendMessageAction.php b/backend/app/Http/Actions/Messages/SendMessageAction.php index 8c72b11049..a09b532919 100644 --- a/backend/app/Http/Actions/Messages/SendMessageAction.php +++ b/backend/app/Http/Actions/Messages/SendMessageAction.php @@ -29,20 +29,24 @@ public function __invoke(SendMessageRequest $request, int $eventId): JsonRespons $user = $this->getAuthenticatedUser(); try { + $validated = $request->validated(); + $message = $this->messageHandler->handle(SendMessageDTO::fromArray([ 'event_id' => $eventId, - 'subject' => $request->input('subject'), - 'message' => $request->input('message'), - 'type' => $request->input('message_type'), - 'is_test' => $request->input('is_test'), - 'order_id' => $request->input('order_id'), - 'attendee_ids' => $request->input('attendee_ids'), - 'product_ids' => $request->input('product_ids'), - 'order_statuses' => $request->input('order_statuses'), - 'send_copy_to_current_user' => $request->boolean('send_copy_to_current_user'), + 'subject' => $validated['subject'], + 'message' => $validated['message'], + 'type' => $validated['message_type'], + 'is_test' => (bool) ($validated['is_test'] ?? false), + 'order_id' => $validated['order_id'] ?? null, + 'attendee_ids' => $validated['attendee_ids'] ?? [], + 'product_ids' => $validated['product_ids'] ?? [], + 'order_statuses' => $validated['order_statuses'] ?? [], + 'send_copy_to_current_user' => (bool) ($validated['send_copy_to_current_user'] ?? false), 'sent_by_user_id' => $user->getId(), 'account_id' => $this->getAuthenticatedAccountId(), - 'scheduled_at' => $request->input('scheduled_at'), + 'scheduled_at' => $validated['scheduled_at'] ?? null, + 'event_occurrence_id' => $validated['event_occurrence_id'] ?? null, + 'event_occurrence_ids' => $validated['event_occurrence_ids'] ?? null, ])); } catch (AccountNotVerifiedException $e) { return $this->errorResponse($e->getMessage(), Response::HTTP_UNAUTHORIZED); diff --git a/backend/app/Http/Actions/Orders/ExportOrdersAction.php b/backend/app/Http/Actions/Orders/ExportOrdersAction.php index 66043857d6..c6b62a286c 100644 --- a/backend/app/Http/Actions/Orders/ExportOrdersAction.php +++ b/backend/app/Http/Actions/Orders/ExportOrdersAction.php @@ -4,12 +4,17 @@ use HiEvents\DomainObjects\Enums\QuestionBelongsTo; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; +use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\QuestionAndAnswerViewDomainObject; use HiEvents\Exports\OrdersExport; use HiEvents\Http\Actions\BaseAction; +use HiEvents\Http\DTO\FilterFieldDTO; use HiEvents\Http\DTO\QueryParamsDTO; +use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\QuestionRepositoryInterface; +use Illuminate\Http\Request; use Maatwebsite\Excel\Facades\Excel; use Symfony\Component\HttpFoundation\BinaryFileResponse; @@ -23,16 +28,37 @@ public function __construct( { } - public function __invoke(int $eventId): BinaryFileResponse + public function __invoke(Request $request, int $eventId): BinaryFileResponse { $this->isActionAuthorized($eventId, EventDomainObject::class); + $eventOccurrenceId = $request->input('event_occurrence_id') ? (int) $request->input('event_occurrence_id') : null; + + $filterFields = collect(); + if ($eventOccurrenceId !== null) { + $filterFields->push(new FilterFieldDTO( + field: 'event_occurrence_id', + operator: 'eq', + value: (string) $eventOccurrenceId, + )); + } + $orders = $this->orderRepository ->setMaxPerPage(10000) ->loadRelation(QuestionAndAnswerViewDomainObject::class) + ->loadRelation(new Relationship( + domainObject: OrderItemDomainObject::class, + nested: [ + new Relationship( + domainObject: EventOccurrenceDomainObject::class, + name: 'event_occurrence', + ), + ], + )) ->findByEventId($eventId, new QueryParamsDTO( page: 1, per_page: 10000, + filter_fields: $filterFields->isNotEmpty() ? $filterFields : null, )); $questions = $this->questionRepository->findWhere([ diff --git a/backend/app/Http/Actions/Orders/GetOrderAction.php b/backend/app/Http/Actions/Orders/GetOrderAction.php index 6947037904..ff831f03da 100644 --- a/backend/app/Http/Actions/Orders/GetOrderAction.php +++ b/backend/app/Http/Actions/Orders/GetOrderAction.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\QuestionAndAnswerViewDomainObject; @@ -32,7 +33,15 @@ public function __invoke(int $eventId, int $orderId): JsonResponse $this->isActionAuthorized($eventId, EventDomainObject::class); $order = $this->orderRepository - ->loadRelation(OrderItemDomainObject::class) + ->loadRelation(new Relationship( + domainObject: OrderItemDomainObject::class, + nested: [ + new Relationship( + domainObject: EventOccurrenceDomainObject::class, + name: 'event_occurrence', + ), + ], + )) ->loadRelation(AttendeeDomainObject::class) ->loadRelation(new Relationship(domainObject: QuestionAndAnswerViewDomainObject::class, orderAndDirections: [ new OrderAndDirection(order: 'question_id'), diff --git a/backend/app/Http/Actions/Orders/GetOrdersAction.php b/backend/app/Http/Actions/Orders/GetOrdersAction.php index c8f9575dc9..16ab6e9e08 100644 --- a/backend/app/Http/Actions/Orders/GetOrdersAction.php +++ b/backend/app/Http/Actions/Orders/GetOrdersAction.php @@ -4,10 +4,12 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\InvoiceDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\Http\Actions\BaseAction; +use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Resources\Order\OrderResource; use Illuminate\Http\JsonResponse; @@ -27,7 +29,15 @@ public function __invoke(Request $request, int $eventId): JsonResponse $this->isActionAuthorized($eventId, EventDomainObject::class); $orders = $this->orderRepository - ->loadRelation(OrderItemDomainObject::class) + ->loadRelation(new Relationship( + domainObject: OrderItemDomainObject::class, + nested: [ + new Relationship( + domainObject: EventOccurrenceDomainObject::class, + name: 'event_occurrence', + ), + ], + )) ->loadRelation(AttendeeDomainObject::class) ->loadRelation(InvoiceDomainObject::class) ->findByEventId($eventId, $this->getPaginationQueryParams($request)); diff --git a/backend/app/Http/Actions/Orders/Public/CreateOrderActionPublic.php b/backend/app/Http/Actions/Orders/Public/CreateOrderActionPublic.php index d2de957f9a..23822973e2 100644 --- a/backend/app/Http/Actions/Orders/Public/CreateOrderActionPublic.php +++ b/backend/app/Http/Actions/Orders/Public/CreateOrderActionPublic.php @@ -20,21 +20,19 @@ class CreateOrderActionPublic extends BaseAction { public function __construct( - private readonly CreateOrderHandler $orderHandler, + private readonly CreateOrderHandler $orderHandler, private readonly OrderCreateRequestValidationService $orderCreateRequestValidationService, - private readonly CheckoutSessionManagementService $sessionIdentifierService, - private readonly LocaleService $localeService, + private readonly CheckoutSessionManagementService $sessionIdentifierService, + private readonly LocaleService $localeService, - ) - { - } + ) {} /** * @throws Throwable */ public function __invoke(CreateOrderRequest $request, int $eventId): JsonResponse { - $this->orderCreateRequestValidationService->validateRequestData($eventId, $request->all()); + $validatedData = $this->orderCreateRequestValidationService->validateRequestData($eventId, $request->all()); $sessionId = $this->sessionIdentifierService->getSessionId(); $order = $this->orderHandler->handle( @@ -43,7 +41,7 @@ public function __invoke(CreateOrderRequest $request, int $eventId): JsonRespons 'is_user_authenticated' => $this->isUserAuthenticated(), 'promo_code' => $request->input('promo_code'), 'affiliate_code' => $request->input('affiliate_code'), - 'products' => ProductOrderDetailsDTO::collectionFromArray($request->input('products')), + 'products' => ProductOrderDetailsDTO::collectionFromArray($validatedData['products']), 'session_identifier' => $sessionId, 'order_locale' => $this->localeService->getLocaleOrDefault($request->getPreferredLanguage()), ]) @@ -51,7 +49,7 @@ public function __invoke(CreateOrderRequest $request, int $eventId): JsonRespons $order->setSessionIdentifier($sessionId); - $response = $this->resourceResponse( + $response = $this->resourceResponse( resource: OrderResourcePublic::class, data: $order, statusCode: ResponseCodes::HTTP_CREATED, diff --git a/backend/app/Http/Actions/Organizers/GetOrganizerAction.php b/backend/app/Http/Actions/Organizers/GetOrganizerAction.php index 896c7cd0cc..e38dda8639 100644 --- a/backend/app/Http/Actions/Organizers/GetOrganizerAction.php +++ b/backend/app/Http/Actions/Organizers/GetOrganizerAction.php @@ -3,7 +3,10 @@ namespace HiEvents\Http\Actions\Organizers; use HiEvents\DomainObjects\ImageDomainObject; +use HiEvents\DomainObjects\OrganizerConfigurationDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; +use HiEvents\DomainObjects\OrganizerStripePlatformDomainObject; +use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Http\Actions\BaseAction; use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; use HiEvents\Resources\Organizer\OrganizerResource; @@ -24,6 +27,11 @@ public function __invoke(int $organizerId): Response $organizer = $this->organizerRepository ->loadRelation(ImageDomainObject::class) + ->loadRelation(OrganizerStripePlatformDomainObject::class) + ->loadRelation(new Relationship( + domainObject: OrganizerConfigurationDomainObject::class, + name: 'organizer_configuration', + )) ->findFirstWhere([ 'id' => $organizerId, 'account_id' => $this->getAuthenticatedAccountId(), diff --git a/backend/app/Http/Actions/Organizers/Stripe/CopyStripeConnectAccountAction.php b/backend/app/Http/Actions/Organizers/Stripe/CopyStripeConnectAccountAction.php new file mode 100644 index 0000000000..0650f8f8e6 --- /dev/null +++ b/backend/app/Http/Actions/Organizers/Stripe/CopyStripeConnectAccountAction.php @@ -0,0 +1,62 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class, Role::ADMIN); + $this->isActionAuthorized($sourceOrganizerId, OrganizerDomainObject::class, Role::ADMIN); + + try { + $result = $this->copyStripeConnectAccountHandler->handle(CopyStripeConnectAccountDTO::from([ + 'targetOrganizerId' => $organizerId, + 'sourceOrganizerId' => $sourceOrganizerId, + 'accountId' => $this->getAuthenticatedAccountId(), + ])); + } catch (SaasModeEnabledException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: Response::HTTP_FORBIDDEN, + ); + } catch (ResourceNotFoundException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: Response::HTTP_NOT_FOUND, + ); + } catch (ResourceConflictException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: Response::HTTP_CONFLICT, + ); + } + + return $this->resourceResponse( + resource: OrganizerStripeConnectAccountResponseResource::class, + data: $result, + ); + } +} diff --git a/backend/app/Http/Actions/Accounts/Stripe/CreateStripeConnectAccountAction.php b/backend/app/Http/Actions/Organizers/Stripe/CreateStripeConnectAccountAction.php similarity index 56% rename from backend/app/Http/Actions/Accounts/Stripe/CreateStripeConnectAccountAction.php rename to backend/app/Http/Actions/Organizers/Stripe/CreateStripeConnectAccountAction.php index 224121ebfb..1809383500 100644 --- a/backend/app/Http/Actions/Accounts/Stripe/CreateStripeConnectAccountAction.php +++ b/backend/app/Http/Actions/Organizers/Stripe/CreateStripeConnectAccountAction.php @@ -1,17 +1,18 @@ isActionAuthorized($accountId, AccountDomainObject::class, Role::ADMIN); + $this->isActionAuthorized($organizerId, OrganizerDomainObject::class, Role::ADMIN); try { - $accountResult = $this->createStripeConnectAccountHandler->handle(CreateStripeConnectAccountDTO::from([ + $result = $this->createStripeConnectAccountHandler->handle(CreateStripeConnectAccountDTO::from([ + 'organizerId' => $organizerId, 'accountId' => $this->getAuthenticatedAccountId(), 'platform' => $request->has('platform') ? StripePlatform::from($request->get('platform')) @@ -42,18 +44,23 @@ public function __invoke(int $accountId, Request $request): JsonResponse } catch (CreateStripeConnectAccountLinksFailedException|CreateStripeConnectAccountFailedException $e) { return $this->errorResponse( message: $e->getMessage(), - statusCode: Response::HTTP_INTERNAL_SERVER_ERROR + statusCode: Response::HTTP_INTERNAL_SERVER_ERROR, ); } catch (SaasModeEnabledException $e) { return $this->errorResponse( message: $e->getMessage(), - statusCode: Response::HTTP_FORBIDDEN + statusCode: Response::HTTP_FORBIDDEN, + ); + } catch (ResourceNotFoundException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: Response::HTTP_NOT_FOUND, ); } return $this->resourceResponse( - resource: StripeConnectAccountResponseResource::class, - data: $accountResult + resource: OrganizerStripeConnectAccountResponseResource::class, + data: $result, ); } } diff --git a/backend/app/Http/Actions/Organizers/Stripe/GetStripeConnectAccountsAction.php b/backend/app/Http/Actions/Organizers/Stripe/GetStripeConnectAccountsAction.php new file mode 100644 index 0000000000..115f870d6e --- /dev/null +++ b/backend/app/Http/Actions/Organizers/Stripe/GetStripeConnectAccountsAction.php @@ -0,0 +1,44 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class, Role::ADMIN); + + try { + $result = $this->getStripeConnectAccountsHandler->handle($organizerId, $this->getAuthenticatedAccountId()); + } catch (ResourceNotFoundException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: Response::HTTP_NOT_FOUND, + ); + } + + return $this->resourceResponse( + resource: OrganizerStripeConnectAccountsResponseResource::class, + data: $result, + ); + } +} diff --git a/backend/app/Http/Actions/Organizers/Vat/GetOrganizerVatSettingAction.php b/backend/app/Http/Actions/Organizers/Vat/GetOrganizerVatSettingAction.php new file mode 100644 index 0000000000..5e506dd477 --- /dev/null +++ b/backend/app/Http/Actions/Organizers/Vat/GetOrganizerVatSettingAction.php @@ -0,0 +1,33 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class); + + $vatSetting = $this->handler->handle($organizerId); + + if (!$vatSetting) { + return $this->jsonResponse(['data' => null]); + } + + return $this->resourceResponse(OrganizerVatSettingResource::class, $vatSetting); + } +} diff --git a/backend/app/Http/Actions/Organizers/Vat/UpsertOrganizerVatSettingAction.php b/backend/app/Http/Actions/Organizers/Vat/UpsertOrganizerVatSettingAction.php new file mode 100644 index 0000000000..8d97b4b9bf --- /dev/null +++ b/backend/app/Http/Actions/Organizers/Vat/UpsertOrganizerVatSettingAction.php @@ -0,0 +1,51 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class, Role::ADMIN); + + $validated = $request->validate([ + 'vat_registered' => 'required|boolean', + 'vat_number' => 'nullable|string|max:20', + ]); + + try { + $vatSetting = $this->handler->handle(new UpsertOrganizerVatSettingDTO( + organizerId: $organizerId, + accountId: $this->getAuthenticatedAccountId(), + vatRegistered: $validated['vat_registered'], + vatNumber: $validated['vat_number'] ?? null, + )); + } catch (ResourceNotFoundException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: Response::HTTP_NOT_FOUND, + ); + } + + return $this->resourceResponse(OrganizerVatSettingResource::class, $vatSetting); + } +} diff --git a/backend/app/Http/Actions/Reports/GetReportAction.php b/backend/app/Http/Actions/Reports/GetReportAction.php index 5fa1596ac3..2ad7248da3 100644 --- a/backend/app/Http/Actions/Reports/GetReportAction.php +++ b/backend/app/Http/Actions/Reports/GetReportAction.php @@ -29,7 +29,7 @@ public function __invoke(GetReportRequest $request, int $eventId, string $report $this->validateDateRange($request); if (!in_array($reportType, ReportTypes::valuesArray(), true)) { - throw new BadRequestHttpException('Invalid report type.'); + throw new BadRequestHttpException(__('Invalid report type.')); } $reportData = $this->reportHandler->handle( @@ -38,6 +38,7 @@ public function __invoke(GetReportRequest $request, int $eventId, string $report reportType: ReportTypes::from($reportType), startDate: $request->validated('start_date'), endDate: $request->validated('end_date'), + occurrenceId: $request->validated('occurrence_id') ? (int) $request->validated('occurrence_id') : null, ), ); @@ -55,7 +56,9 @@ private function validateDateRange(GetReportRequest $request): void $diffInDays = Carbon::parse($startDate)->diffInDays(Carbon::parse($endDate)); if ($diffInDays > 370) { - throw ValidationException::withMessages(['start_date' => 'Date range must be less than 370 days.']); + throw ValidationException::withMessages([ + 'start_date' => __('Date range must be less than 370 days.'), + ]); } } } diff --git a/backend/app/Http/Actions/SelfService/ResendAttendeeTicketPublicAction.php b/backend/app/Http/Actions/SelfService/ResendAttendeeTicketPublicAction.php index 594f7c2121..db56cade26 100644 --- a/backend/app/Http/Actions/SelfService/ResendAttendeeTicketPublicAction.php +++ b/backend/app/Http/Actions/SelfService/ResendAttendeeTicketPublicAction.php @@ -2,20 +2,21 @@ namespace HiEvents\Http\Actions\SelfService; +use HiEvents\Exceptions\ResourceConflictException; use HiEvents\Exceptions\SelfServiceDisabledException; use HiEvents\Http\Actions\BaseAction; use HiEvents\Services\Application\Handlers\SelfService\DTO\ResendEmailPublicDTO; use HiEvents\Services\Application\Handlers\SelfService\ResendAttendeeTicketPublicHandler; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Http\Response; use Symfony\Component\Routing\Exception\ResourceNotFoundException; class ResendAttendeeTicketPublicAction extends BaseAction { public function __construct( private readonly ResendAttendeeTicketPublicHandler $handler - ) { - } + ) {} public function __invoke( Request $request, @@ -39,6 +40,8 @@ public function __invoke( return $this->errorResponse($e->getMessage(), $e->getCode()); } catch (ResourceNotFoundException $e) { return $this->errorResponse($e->getMessage(), 404); + } catch (ResourceConflictException $e) { + return $this->errorResponse($e->getMessage(), Response::HTTP_CONFLICT); } } } diff --git a/backend/app/Http/Actions/Waitlist/Organizer/GetWaitlistStatsAction.php b/backend/app/Http/Actions/Waitlist/Organizer/GetWaitlistStatsAction.php index ef72fe63ef..63c8a1f717 100644 --- a/backend/app/Http/Actions/Waitlist/Organizer/GetWaitlistStatsAction.php +++ b/backend/app/Http/Actions/Waitlist/Organizer/GetWaitlistStatsAction.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Http\Actions\BaseAction; +use HiEvents\Http\Request\Waitlist\GetWaitlistStatsRequest; use HiEvents\Services\Application\Handlers\Waitlist\GetWaitlistStatsHandler; use Illuminate\Http\JsonResponse; @@ -15,11 +16,15 @@ public function __construct( { } - public function __invoke(int $eventId): JsonResponse + public function __invoke(GetWaitlistStatsRequest $request, int $eventId): JsonResponse { $this->isActionAuthorized($eventId, EventDomainObject::class); + $validated = $request->validated(); - $stats = $this->handler->handle($eventId); + $stats = $this->handler->handle( + $eventId, + isset($validated['event_occurrence_id']) ? (int) $validated['event_occurrence_id'] : null, + ); return $this->jsonResponse([ 'total' => $stats->total, diff --git a/backend/app/Http/Actions/Waitlist/Organizer/OfferWaitlistEntryAction.php b/backend/app/Http/Actions/Waitlist/Organizer/OfferWaitlistEntryAction.php index 43b22a3bf7..8c26f93064 100644 --- a/backend/app/Http/Actions/Waitlist/Organizer/OfferWaitlistEntryAction.php +++ b/backend/app/Http/Actions/Waitlist/Organizer/OfferWaitlistEntryAction.php @@ -33,6 +33,7 @@ public function __invoke(OfferWaitlistEntryRequest $request, int $eventId): Json product_price_id: $request->validated('product_price_id'), entry_id: $request->validated('entry_id'), quantity: $request->validated('quantity') ?? 1, + event_occurrence_id: $request->validated('event_occurrence_id'), )); } catch (NoCapacityAvailableException $exception) { throw ValidationException::withMessages([ diff --git a/backend/app/Http/Actions/Waitlist/Public/CreateWaitlistEntryActionPublic.php b/backend/app/Http/Actions/Waitlist/Public/CreateWaitlistEntryActionPublic.php index 5dca378c5b..a0c7ad69fc 100644 --- a/backend/app/Http/Actions/Waitlist/Public/CreateWaitlistEntryActionPublic.php +++ b/backend/app/Http/Actions/Waitlist/Public/CreateWaitlistEntryActionPublic.php @@ -30,6 +30,7 @@ public function __invoke(CreateWaitlistEntryRequest $request, int $eventId): Jso first_name: $request->validated('first_name'), last_name: $request->validated('last_name'), locale: $request->input('locale', 'en'), + event_occurrence_id: $request->validated('event_occurrence_id'), )); } catch (ResourceConflictException $e) { return $this->errorResponse( diff --git a/backend/app/Http/Request/Attendee/CreateAttendeeRequest.php b/backend/app/Http/Request/Attendee/CreateAttendeeRequest.php index c73fb80acf..3f0e3fd4d7 100644 --- a/backend/app/Http/Request/Attendee/CreateAttendeeRequest.php +++ b/backend/app/Http/Request/Attendee/CreateAttendeeRequest.php @@ -11,9 +11,12 @@ class CreateAttendeeRequest extends BaseRequest { public function rules(): array { + $eventId = $this->route('event_id'); + return [ 'product_id' => ['int', 'required'], - 'product_price_id' => ['int', 'nullable', 'required'], + 'event_occurrence_id' => ['int', 'nullable', Rule::exists('event_occurrences', 'id')->where('event_id', $eventId)->whereNull('deleted_at')], + 'product_price_id' => ['int', 'nullable'], 'email' => ['required', 'email'], 'first_name' => ['string', 'required', 'max:40'], 'last_name' => ['string', 'max:40'], @@ -23,6 +26,10 @@ public function rules(): array 'taxes_and_fees.*.tax_or_fee_id' => ['required', 'int'], 'taxes_and_fees.*.amount' => ['required', ...RulesHelper::MONEY], 'locale' => ['required', Rule::in(Locale::getSupportedLocales())], + // Organiser opt-in: skip the occurrence-capacity ceiling. Status, + // sold-out, and product visibility checks still apply. Audited via + // OrderAuditAction::MANUAL_ATTENDEE_CAPACITY_OVERRIDE. + 'override_capacity' => ['boolean', 'sometimes'], ]; } } diff --git a/backend/app/Http/Request/CheckInList/UpsertCheckInListRequest.php b/backend/app/Http/Request/CheckInList/UpsertCheckInListRequest.php index 06372e6760..1672c62514 100644 --- a/backend/app/Http/Request/CheckInList/UpsertCheckInListRequest.php +++ b/backend/app/Http/Request/CheckInList/UpsertCheckInListRequest.php @@ -4,17 +4,31 @@ use HiEvents\Http\Request\BaseRequest; use HiEvents\Validators\Rules\RulesHelper; +use Illuminate\Validation\Rule; class UpsertCheckInListRequest extends BaseRequest { public function rules(): array { + $eventId = $this->route('event_id'); + return [ 'name' => RulesHelper::REQUIRED_STRING, - 'description' => ['nullable', 'string', 'max:255'], + 'description' => ['nullable', 'string', 'max:2000'], 'expires_at' => ['nullable', 'date'], 'activates_at' => ['nullable', 'date'], - 'product_ids' => ['required', 'array', 'min:1'], + // Empty/absent = "covers every ticket on the event". + 'product_ids' => ['nullable', 'array'], + 'event_occurrence_id' => [ + 'nullable', + 'integer', + Rule::exists('event_occurrences', 'id') + ->where('event_id', $eventId) + ->whereNull('deleted_at'), + ], + 'public_show_attendee_notes' => ['nullable', 'boolean'], + 'public_show_question_answers' => ['nullable', 'boolean'], + 'public_show_order_details' => ['nullable', 'boolean'], ]; } @@ -32,7 +46,6 @@ public function withValidator($validator): void public function messages(): array { return [ - 'product_ids.required' => __('Please select at least one product.'), 'expires_at.after' => __('The expiration date must be after the activation date.'), 'activates_at.before' => __('The activation date must be before the expiration date.'), ]; diff --git a/backend/app/Http/Request/Event/DuplicateEventRequest.php b/backend/app/Http/Request/Event/DuplicateEventRequest.php index 26959d7aea..5a3c68cddb 100644 --- a/backend/app/Http/Request/Event/DuplicateEventRequest.php +++ b/backend/app/Http/Request/Event/DuplicateEventRequest.php @@ -24,6 +24,7 @@ public function rules(): array 'duplicate_webhooks' => ['boolean', 'required'], 'duplicate_affiliates' => ['boolean', 'required'], 'duplicate_ticket_logo' => ['boolean', 'required'], + 'duplicate_occurrences' => ['boolean', 'nullable'], ]; return array_merge($eventValidations, $duplicateValidations); diff --git a/backend/app/Http/Request/EventOccurrence/BulkUpdateOccurrencesRequest.php b/backend/app/Http/Request/EventOccurrence/BulkUpdateOccurrencesRequest.php new file mode 100644 index 0000000000..b0ff0d328e --- /dev/null +++ b/backend/app/Http/Request/EventOccurrence/BulkUpdateOccurrencesRequest.php @@ -0,0 +1,54 @@ + ['required', 'string', Rule::in(BulkOccurrenceAction::valuesArray())], + 'start_time_shift' => ['nullable', 'integer', 'min:-525600', 'max:525600'], + 'end_time_shift' => ['nullable', 'integer', 'min:-525600', 'max:525600'], + 'capacity' => ['nullable', 'integer', 'min:0'], + 'clear_capacity' => ['nullable', 'boolean'], + 'future_only' => ['nullable', 'boolean'], + 'skip_overridden' => ['nullable', 'boolean'], + 'refund_orders' => ['nullable', 'boolean'], + // Caller MUST either name the occurrences explicitly (occurrence_ids + // non-empty) or opt in to event-wide application via apply_to_all=true. + // Previously an absent/empty occurrence_ids silently meant "every + // matching occurrence" — a footgun the bulk-edit modal hit by accident. + 'apply_to_all' => ['nullable', 'boolean'], + 'occurrence_ids' => ['array'], + 'occurrence_ids.*' => ['integer'], + 'label' => ['nullable', 'string', 'max:255'], + 'clear_label' => ['nullable', 'boolean'], + 'duration_minutes' => ['nullable', 'integer', 'min:1'], + ]; + } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + $applyToAll = (bool) $this->input('apply_to_all', false); + $occurrenceIds = $this->input('occurrence_ids'); + + if ($applyToAll) { + return; + } + + if (! is_array($occurrenceIds) || count($occurrenceIds) === 0) { + $validator->errors()->add( + 'occurrence_ids', + __('Specify at least one occurrence_id, or set apply_to_all to true to update every matching occurrence.'), + ); + } + }); + } +} diff --git a/backend/app/Http/Request/EventOccurrence/CancelOccurrenceRequest.php b/backend/app/Http/Request/EventOccurrence/CancelOccurrenceRequest.php new file mode 100644 index 0000000000..f05cfee539 --- /dev/null +++ b/backend/app/Http/Request/EventOccurrence/CancelOccurrenceRequest.php @@ -0,0 +1,15 @@ + ['nullable', 'boolean'], + ]; + } +} diff --git a/backend/app/Http/Request/EventOccurrence/GenerateOccurrencesRequest.php b/backend/app/Http/Request/EventOccurrence/GenerateOccurrencesRequest.php new file mode 100644 index 0000000000..20915d1180 --- /dev/null +++ b/backend/app/Http/Request/EventOccurrence/GenerateOccurrencesRequest.php @@ -0,0 +1,68 @@ + ['required', 'array'], + 'recurrence_rule.frequency' => ['required', 'string', 'in:daily,weekly,monthly,yearly'], + 'recurrence_rule.interval' => ['nullable', 'integer', 'min:1'], + 'recurrence_rule.range' => ['required', 'array'], + 'recurrence_rule.range.type' => ['required', 'string', 'in:count,until'], + 'recurrence_rule.range.count' => ['required_if:recurrence_rule.range.type,count', 'integer', 'min:1', 'max:1200'], + 'recurrence_rule.range.until' => ['required_if:recurrence_rule.range.type,until', 'date'], + 'recurrence_rule.range.start' => ['nullable', 'date'], + 'recurrence_rule.days_of_week' => ['required_if:recurrence_rule.frequency,weekly', 'array'], + 'recurrence_rule.days_of_week.*' => ['string', 'in:monday,tuesday,wednesday,thursday,friday,saturday,sunday'], + 'recurrence_rule.times_of_day' => ['nullable', 'array', 'max:24'], + 'recurrence_rule.times_of_day.*' => [function ($attribute, $value, $fail) { + if (is_string($value)) { + if (!preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $value)) { + $fail(__('Each time of day must be in HH:MM 24-hour format.')); + } + return; + } + + // For array entries we validate `time` here rather than via the + // sibling `*.time` rule because Laravel's `required_if:foo,value` + // compares foo to the literal string "value" and has no built-in + // way to express "required when the parent is an array" — the + // sibling rule never fires for these entries. + if (is_array($value)) { + if (! isset($value['time']) || ! is_string($value['time'])) { + $fail(__('Each time of day object must include a time field.')); + return; + } + if (! preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $value['time'])) { + $fail(__('Each time of day must be in HH:MM 24-hour format.')); + } + return; + } + + $fail(__('Each time of day must be a HH:MM string or an object with a time field.')); + }], + 'recurrence_rule.times_of_day.*.label' => ['nullable', 'string', 'max:255'], + 'recurrence_rule.times_of_day.*.duration_minutes' => ['nullable', 'integer', 'min:1', 'max:10080'], + 'recurrence_rule.duration_minutes' => ['nullable', 'integer', 'min:1', 'max:10080'], + 'recurrence_rule.default_capacity' => ['nullable', 'integer', 'min:0'], + 'recurrence_rule.excluded_dates' => ['nullable', 'array', 'max:1200'], + 'recurrence_rule.excluded_dates.*' => ['date'], + 'recurrence_rule.excluded_occurrences' => ['nullable', 'array', 'max:1200'], + 'recurrence_rule.excluded_occurrences.*' => ['date_format:Y-m-d H:i'], + 'recurrence_rule.additional_dates' => ['nullable', 'array', 'max:1200'], + 'recurrence_rule.additional_dates.*.date' => ['required', 'date'], + 'recurrence_rule.additional_dates.*.time' => ['nullable', 'string', 'date_format:H:i'], + 'recurrence_rule.monthly_pattern' => ['nullable', 'string', 'in:by_day_of_month,by_day_of_week'], + 'recurrence_rule.days_of_month' => ['nullable', 'array'], + 'recurrence_rule.days_of_month.*' => ['integer', 'min:1', 'max:31'], + 'recurrence_rule.day_of_week' => ['nullable', 'string', 'in:monday,tuesday,wednesday,thursday,friday,saturday,sunday'], + 'recurrence_rule.week_position' => ['nullable', 'integer', 'in:-1,1,2,3,4'], + 'recurrence_rule.month' => ['nullable', 'integer', 'min:1', 'max:12'], + ]; + } +} diff --git a/backend/app/Http/Request/EventOccurrence/ReactivateOccurrenceRequest.php b/backend/app/Http/Request/EventOccurrence/ReactivateOccurrenceRequest.php new file mode 100644 index 0000000000..e89545ef08 --- /dev/null +++ b/backend/app/Http/Request/EventOccurrence/ReactivateOccurrenceRequest.php @@ -0,0 +1,13 @@ + ['required', 'array', 'min:1'], + 'product_ids.*' => ['integer', 'distinct'], + ]; + } + + public function messages(): array + { + return [ + 'product_ids.min' => __('Select at least one product. To make a date inaccessible, cancel it from the schedule instead.'), + ]; + } +} diff --git a/backend/app/Http/Request/EventOccurrence/UpsertEventOccurrenceRequest.php b/backend/app/Http/Request/EventOccurrence/UpsertEventOccurrenceRequest.php new file mode 100644 index 0000000000..ea6005c304 --- /dev/null +++ b/backend/app/Http/Request/EventOccurrence/UpsertEventOccurrenceRequest.php @@ -0,0 +1,26 @@ + ['required', 'date'], + 'end_date' => ['nullable', 'date', 'after:start_date'], + 'capacity' => ['nullable', 'integer', 'min:0'], + 'label' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/backend/app/Http/Request/EventOccurrence/UpsertPriceOverrideRequest.php b/backend/app/Http/Request/EventOccurrence/UpsertPriceOverrideRequest.php new file mode 100644 index 0000000000..e7cbdf7efc --- /dev/null +++ b/backend/app/Http/Request/EventOccurrence/UpsertPriceOverrideRequest.php @@ -0,0 +1,16 @@ + ['required', 'integer'], + 'price' => ['required', 'numeric', 'min:0', 'max:100000000'], + ]; + } +} diff --git a/backend/app/Http/Request/Message/SendMessageRequest.php b/backend/app/Http/Request/Message/SendMessageRequest.php index 5b12e009c6..52e382c930 100644 --- a/backend/app/Http/Request/Message/SendMessageRequest.php +++ b/backend/app/Http/Request/Message/SendMessageRequest.php @@ -5,37 +5,69 @@ use HiEvents\DomainObjects\Enums\MessageTypeEnum; use HiEvents\DomainObjects\Status\OrderStatus; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\In; class SendMessageRequest extends FormRequest { public function rules(): array { + $eventId = $this->route('event_id'); + return [ 'subject' => 'required|string|max:100', 'message' => 'required|string|max:8000', 'message_type' => [new In(MessageTypeEnum::valuesArray()), 'required'], 'is_test' => 'boolean', - 'attendee_ids' => 'max:50,array|required_if:message_type,' . MessageTypeEnum::INDIVIDUAL_ATTENDEES->name, + 'send_copy_to_current_user' => 'boolean', + 'attendee_ids' => 'max:50,array|required_if:message_type,'.MessageTypeEnum::INDIVIDUAL_ATTENDEES->name, 'attendee_ids.*' => 'integer', - 'product_ids' => ['array', 'required_if:message_type,' . MessageTypeEnum::TICKET_HOLDERS->name], - 'order_id' => 'integer|required_if:message_type,' . MessageTypeEnum::ORDER_OWNER->name, + 'product_ids' => ['array', 'required_if:message_type,'.MessageTypeEnum::TICKET_HOLDERS->name], + 'order_id' => 'integer|required_if:message_type,'.MessageTypeEnum::ORDER_OWNER->name, 'product_ids.*' => 'integer', 'order_statuses.*' => [ - 'required_if:message_type,' . MessageTypeEnum::ORDER_OWNERS_WITH_PRODUCT->name, + 'required_if:message_type,'.MessageTypeEnum::ORDER_OWNERS_WITH_PRODUCT->name, new In([OrderStatus::COMPLETED->name, OrderStatus::AWAITING_OFFLINE_PAYMENT->name]), ], 'scheduled_at' => 'nullable|date', + 'event_occurrence_id' => [ + 'nullable', + 'integer', + Rule::exists('event_occurrences', 'id') + ->where('event_id', $eventId) + ->whereNull('deleted_at'), + ], + // Targets attendees across multiple occurrences (e.g. after a bulk + // reschedule). Mutually exclusive with event_occurrence_id. + 'event_occurrence_ids' => ['nullable', 'array', 'max:500'], + 'event_occurrence_ids.*' => [ + 'integer', + Rule::exists('event_occurrences', 'id') + ->where('event_id', $eventId) + ->whereNull('deleted_at'), + ], ]; } + public function withValidator($validator): void + { + $validator->after(function ($validator) { + if ($this->filled('event_occurrence_id') && $this->filled('event_occurrence_ids')) { + $validator->errors()->add( + 'event_occurrence_ids', + __('Only one of event_occurrence_id or event_occurrence_ids may be provided.') + ); + } + }); + } + public function messages(): array { return [ - 'order_statuses.required_if' => 'The order statuses field is required when sending messages to order owners with a specific product.', - 'subject.max' => 'The subject must be less than 100 characters.', - 'attendee_ids.max' => 'You can only send a message to a maximum of 50 individual attendees at a time. ' . - 'To message more attendees, you can send to attendees with a specific product, or to all event attendees.' + 'order_statuses.required_if' => __('The order statuses field is required when sending messages to order owners with a specific product.'), + 'subject.max' => __('The subject must be less than 100 characters.'), + 'attendee_ids.max' => __('You can only send a message to a maximum of 50 individual attendees at a time. To message more attendees, you can send to attendees with a specific product, or to all event attendees.'), + 'event_occurrence_ids.max' => __('You can only target up to 500 occurrences in a single message.'), ]; } } diff --git a/backend/app/Http/Request/Report/GetReportRequest.php b/backend/app/Http/Request/Report/GetReportRequest.php index 458a9861df..4a822df311 100644 --- a/backend/app/Http/Request/Report/GetReportRequest.php +++ b/backend/app/Http/Request/Report/GetReportRequest.php @@ -3,14 +3,24 @@ namespace HiEvents\Http\Request\Report; use HiEvents\Http\Request\BaseRequest; +use Illuminate\Validation\Rule; class GetReportRequest extends BaseRequest { public function rules(): array { + $eventId = $this->route('event_id') ?? $this->route('eventId'); + return [ 'start_date' => 'date|before:end_date|required_with:end_date|nullable', 'end_date' => 'date|after:start_date|required_with:start_date|nullable', + 'occurrence_id' => [ + 'integer', + 'nullable', + Rule::exists('event_occurrences', 'id') + ->where('event_id', $eventId) + ->whereNull('deleted_at'), + ], ]; } } diff --git a/backend/app/Http/Request/Waitlist/CreateWaitlistEntryRequest.php b/backend/app/Http/Request/Waitlist/CreateWaitlistEntryRequest.php index 1fe7aea86a..3a4db5a598 100644 --- a/backend/app/Http/Request/Waitlist/CreateWaitlistEntryRequest.php +++ b/backend/app/Http/Request/Waitlist/CreateWaitlistEntryRequest.php @@ -3,13 +3,23 @@ namespace HiEvents\Http\Request\Waitlist; use HiEvents\Http\Request\BaseRequest; +use Illuminate\Validation\Rule; class CreateWaitlistEntryRequest extends BaseRequest { public function rules(): array { + $eventId = $this->route('event_id'); + return [ 'product_price_id' => ['required', 'integer', 'exists:product_prices,id'], + 'event_occurrence_id' => [ + 'nullable', + 'integer', + Rule::exists('event_occurrences', 'id') + ->where('event_id', $eventId) + ->whereNull('deleted_at'), + ], 'email' => ['required', 'email', 'max:255'], 'first_name' => ['required', 'string', 'max:255'], 'last_name' => ['nullable', 'string', 'max:255'], diff --git a/backend/app/Http/Request/Waitlist/GetWaitlistStatsRequest.php b/backend/app/Http/Request/Waitlist/GetWaitlistStatsRequest.php new file mode 100644 index 0000000000..67d59a6f90 --- /dev/null +++ b/backend/app/Http/Request/Waitlist/GetWaitlistStatsRequest.php @@ -0,0 +1,24 @@ +route('event_id'); + + return [ + 'event_occurrence_id' => [ + 'nullable', + 'integer', + Rule::exists('event_occurrences', 'id') + ->where('event_id', $eventId) + ->whereNull('deleted_at'), + ], + ]; + } +} diff --git a/backend/app/Http/Request/Waitlist/OfferWaitlistEntryRequest.php b/backend/app/Http/Request/Waitlist/OfferWaitlistEntryRequest.php index f04e6d334e..5fa5dbe6ff 100644 --- a/backend/app/Http/Request/Waitlist/OfferWaitlistEntryRequest.php +++ b/backend/app/Http/Request/Waitlist/OfferWaitlistEntryRequest.php @@ -3,15 +3,25 @@ namespace HiEvents\Http\Request\Waitlist; use HiEvents\Http\Request\BaseRequest; +use Illuminate\Validation\Rule; class OfferWaitlistEntryRequest extends BaseRequest { public function rules(): array { + $eventId = $this->route('event_id'); + return [ 'product_price_id' => ['required_without:entry_id', 'integer', 'exists:product_prices,id'], 'entry_id' => ['required_without:product_price_id', 'integer', 'exists:waitlist_entries,id'], 'quantity' => ['sometimes', 'integer', 'min:1', 'max:50'], + 'event_occurrence_id' => [ + 'nullable', + 'integer', + Rule::exists('event_occurrences', 'id') + ->where('event_id', $eventId) + ->whereNull('deleted_at'), + ], ]; } } diff --git a/backend/app/Jobs/Event/SendMessagesJob.php b/backend/app/Jobs/Event/SendMessagesJob.php index 142f322fc2..fe6cc953ad 100644 --- a/backend/app/Jobs/Event/SendMessagesJob.php +++ b/backend/app/Jobs/Event/SendMessagesJob.php @@ -15,12 +15,9 @@ class SendMessagesJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - private SendMessageDTO $messageData; - - public function __construct(SendMessageDTO $messageData) - { - $this->messageData = $messageData; - } + public function __construct( + public readonly SendMessageDTO $messageData, + ) {} /** * @throws UnableToSendMessageException diff --git a/backend/app/Jobs/Occurrence/BulkCancelOccurrencesJob.php b/backend/app/Jobs/Occurrence/BulkCancelOccurrencesJob.php new file mode 100644 index 0000000000..cabd29586b --- /dev/null +++ b/backend/app/Jobs/Occurrence/BulkCancelOccurrencesJob.php @@ -0,0 +1,132 @@ +onQueue('occurrences'); + } + + public function handle( + EventOccurrenceRepositoryInterface $occurrenceRepository, + RecurrenceRuleExclusionService $exclusionService, + CancelOccurrenceAttendeesService $cancelAttendeesService, + ): void { + $cancelledStartDates = []; + $failedIds = []; + + foreach ($this->occurrenceIds as $occurrenceId) { + try { + // Each iteration runs in its own transaction so we can lock the occurrence + // row before checking its status. Without the lock, two concurrent bulk + // cancellations could both observe ACTIVE and dispatch the refund / + // notification side-effects twice. + $cancelledStartDate = DB::transaction(function () use ($occurrenceRepository, $cancelAttendeesService, $occurrenceId) { + $occurrence = $occurrenceRepository->findByIdLocked($occurrenceId); + + if ( + ! $occurrence + || $occurrence->getEventId() !== $this->eventId + || $occurrence->getStatus() === EventOccurrenceStatus::CANCELLED->name + ) { + return null; + } + + $occurrenceRepository->updateWhere( + attributes: [ + EventOccurrenceDomainObjectAbstract::STATUS => EventOccurrenceStatus::CANCELLED->name, + ], + where: [EventOccurrenceDomainObjectAbstract::ID => $occurrenceId], + ); + + $cancelAttendeesService->cancelForOccurrence($this->eventId, $occurrenceId); + + return $occurrence->getStartDate(); + }); + + if ($cancelledStartDate === null) { + continue; + } + + event(new OccurrenceCancelledEvent( + eventId: $this->eventId, + occurrenceId: $occurrenceId, + refundOrders: $this->refundOrders, + )); + + event(new OccurrenceEvent( + type: DomainEventType::OCCURRENCE_CANCELLED, + occurrenceId: $occurrenceId, + )); + + $cancelledStartDates[] = $cancelledStartDate; + } catch (\Throwable $e) { + $failedIds[] = $occurrenceId; + Log::error('Failed to cancel occurrence', [ + 'event_id' => $this->eventId, + 'occurrence_id' => $occurrenceId, + 'error' => $e->getMessage(), + ]); + } + } + + if (! empty($cancelledStartDates)) { + DB::transaction(fn () => $exclusionService->addExclusions($this->eventId, $cancelledStartDates)); + } + + $context = [ + 'event_id' => $this->eventId, + 'cancelled_count' => count($cancelledStartDates), + 'failed_count' => count($failedIds), + 'failed_ids' => $failedIds, + 'refund_orders' => $this->refundOrders, + 'peak_memory_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 1), + ]; + + if (empty($failedIds)) { + Log::info('Bulk cancel occurrences completed', $context); + } else { + Log::warning('Bulk cancel occurrences completed with failures', $context); + } + } + + public function failed(Throwable $exception): void + { + Log::critical('BulkCancelOccurrencesJob permanently failed after retries', [ + 'event_id' => $this->eventId, + 'occurrence_ids' => $this->occurrenceIds, + 'refund_orders' => $this->refundOrders, + 'error' => $exception->getMessage(), + ]); + } +} diff --git a/backend/app/Jobs/Occurrence/RefundOccurrenceOrdersJob.php b/backend/app/Jobs/Occurrence/RefundOccurrenceOrdersJob.php new file mode 100644 index 0000000000..1b8d280837 --- /dev/null +++ b/backend/app/Jobs/Occurrence/RefundOccurrenceOrdersJob.php @@ -0,0 +1,169 @@ +onQueue('occurrences'); + } + + public function uniqueId(): string + { + return "occurrence:{$this->occurrenceId}"; + } + + public function handle( + RefundOrderHandler $refundHandler, + OrderAuditLogRepositoryInterface $auditLogRepository, + ): void { + $orderIds = DB::table('order_items') + ->where(OrderItemDomainObjectAbstract::EVENT_OCCURRENCE_ID, $this->occurrenceId) + ->whereNull('deleted_at') + ->distinct() + ->pluck('order_id'); + + if ($orderIds->isEmpty()) { + return; + } + + // Only refund orders that haven't already had a refund started. Skipping rows where + // refund_status is set guards against duplicate Stripe refunds on job retry when a + // previous attempt crashed between the Stripe API call and the refund_status write. + $refundableOrders = DB::table('orders') + ->whereIn('id', $orderIds) + ->where('status', OrderStatus::COMPLETED->name) + ->where('payment_status', OrderPaymentStatus::PAYMENT_RECEIVED->name) + ->whereNull('refund_status') + ->get(['id', 'total_gross', 'currency']); + + if ($refundableOrders->isEmpty()) { + return; + } + + $multiOccurrenceOrderIds = DB::table('order_items') + ->whereIn('order_id', $refundableOrders->pluck('id')) + ->whereNull('deleted_at') + ->select('order_id') + ->groupBy('order_id') + ->havingRaw('COUNT(DISTINCT event_occurrence_id) > 1') + ->pluck('order_id') + ->toArray(); + + foreach ($refundableOrders as $order) { + if (in_array($order->id, $multiOccurrenceOrderIds, true)) { + Log::warning('Skipping automatic refund for order spanning multiple occurrences', [ + 'order_id' => $order->id, + 'event_id' => $this->eventId, + 'cancelled_occurrence_id' => $this->occurrenceId, + ]); + + // Surface the skip on the order's audit log so admins see it in + // the order's history and can issue a manual partial refund. + // Wrapped: audit-log failure shouldn't derail the rest of the + // batch (other orders are still queued for refund/skip below). + try { + $auditLogRepository->create([ + 'event_id' => $this->eventId, + 'order_id' => $order->id, + 'attendee_id' => null, + 'action' => OrderAuditAction::AUTOMATIC_REFUND_SKIPPED->value, + 'old_values' => null, + 'new_values' => [ + 'cancelled_occurrence_id' => $this->occurrenceId, + 'reason' => 'order spans multiple occurrences', + ], + 'changed_fields' => null, + 'ip_address' => null, + 'user_agent' => null, + ]); + } catch (Throwable $e) { + Log::error('Failed to write refund-skipped audit log entry', [ + 'order_id' => $order->id, + 'event_id' => $this->eventId, + 'error' => $e->getMessage(), + ]); + } + + continue; + } + + try { + $refundHandler->handle(new RefundOrderDTO( + event_id: $this->eventId, + order_id: $order->id, + amount: (float) $order->total_gross, + notify_buyer: true, + cancel_order: true, + )); + } catch (Throwable $e) { + Log::error('Failed to refund order for cancelled occurrence', [ + 'order_id' => $order->id, + 'event_id' => $this->eventId, + 'occurrence_id' => $this->occurrenceId, + 'error' => $e->getMessage(), + ]); + + try { + $auditLogRepository->create([ + 'event_id' => $this->eventId, + 'order_id' => $order->id, + 'attendee_id' => null, + 'action' => OrderAuditAction::AUTOMATIC_REFUND_FAILED->value, + 'old_values' => null, + 'new_values' => [ + 'cancelled_occurrence_id' => $this->occurrenceId, + 'error' => $e->getMessage(), + ], + 'changed_fields' => null, + 'ip_address' => null, + 'user_agent' => null, + ]); + } catch (Throwable $auditError) { + Log::error('Failed to write refund-failed audit log entry', [ + 'order_id' => $order->id, + 'event_id' => $this->eventId, + 'error' => $auditError->getMessage(), + ]); + } + } + } + } + + public function failed(Throwable $exception): void + { + Log::critical('RefundOccurrenceOrdersJob permanently failed after retries', [ + 'event_id' => $this->eventId, + 'occurrence_id' => $this->occurrenceId, + 'error' => $exception->getMessage(), + ]); + } +} diff --git a/backend/app/Jobs/Occurrence/SendOccurrenceCancellationEmailJob.php b/backend/app/Jobs/Occurrence/SendOccurrenceCancellationEmailJob.php new file mode 100644 index 0000000000..e872cf2472 --- /dev/null +++ b/backend/app/Jobs/Occurrence/SendOccurrenceCancellationEmailJob.php @@ -0,0 +1,107 @@ +onQueue('occurrences'); + } + + public function handle( + EventRepositoryInterface $eventRepository, + EventOccurrenceRepositoryInterface $occurrenceRepository, + AttendeeRepositoryInterface $attendeeRepository, + Mailer $mailer, + MailBuilderService $mailBuilderService, + ): void { + $occurrence = $occurrenceRepository->findById($this->occurrenceId); + + $event = $eventRepository + ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) + ->loadRelation(new Relationship(EventSettingDomainObject::class)) + ->findById($this->eventId); + + // Intentionally does NOT filter out CANCELLED attendees: + // CancelOccurrenceHandler now marks attendees as CANCELLED inside the + // same transaction that fires this job's event — a status filter here + // would exclude the very attendees we need to notify. Anyone tied to + // this occurrence by FK gets the cancellation email. Dedup by email + // address below handles shared-email attendees. + $attendees = $attendeeRepository->findWhere([ + AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID => $this->occurrenceId, + ]); + + if ($attendees->isEmpty()) { + return; + } + + $sentEmails = []; + + $attendees->each(function (AttendeeDomainObject $attendee) use ($mailer, $mailBuilderService, $event, $occurrence, &$sentEmails) { + if (in_array($attendee->getEmail(), $sentEmails, true)) { + return; + } + + $sentEmails[] = $attendee->getEmail(); + + $mail = $mailBuilderService->buildOccurrenceCancellationMail( + event: $event, + occurrence: $occurrence, + organizer: $event->getOrganizer(), + eventSettings: $event->getEventSettings(), + refundOrders: $this->refundOrders, + ); + + $mailer + ->to($attendee->getEmail()) + ->locale($attendee->getLocale()) + ->send($mail); + }); + + Log::info('Sent occurrence cancellation emails', [ + 'event_id' => $this->eventId, + 'occurrence_id' => $this->occurrenceId, + 'recipient_count' => count($sentEmails), + 'peak_memory_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 1), + ]); + } + + public function failed(Throwable $exception): void + { + Log::critical('SendOccurrenceCancellationEmailJob permanently failed after retries', [ + 'event_id' => $this->eventId, + 'occurrence_id' => $this->occurrenceId, + 'refund_orders' => $this->refundOrders, + 'error' => $exception->getMessage(), + ]); + } +} diff --git a/backend/app/Jobs/Order/Webhook/DispatchOccurrenceWebhookJob.php b/backend/app/Jobs/Order/Webhook/DispatchOccurrenceWebhookJob.php new file mode 100644 index 0000000000..57b29a949a --- /dev/null +++ b/backend/app/Jobs/Order/Webhook/DispatchOccurrenceWebhookJob.php @@ -0,0 +1,30 @@ +dispatchOccurrenceWebhook( + eventType: $this->eventType, + occurrenceId: $this->occurrenceId, + ); + } +} diff --git a/backend/app/Jobs/Vat/ValidateVatNumberJob.php b/backend/app/Jobs/Vat/ValidateVatNumberJob.php index 2d97a8917d..f6ce085b68 100644 --- a/backend/app/Jobs/Vat/ValidateVatNumberJob.php +++ b/backend/app/Jobs/Vat/ValidateVatNumberJob.php @@ -6,7 +6,7 @@ use DateTimeInterface; use HiEvents\DomainObjects\Status\VatValidationStatus; -use HiEvents\Repository\Interfaces\AccountVatSettingRepositoryInterface; +use HiEvents\Repository\Interfaces\OrganizerVatSettingRepositoryInterface; use HiEvents\Services\Infrastructure\Vat\ViesValidationService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -27,22 +27,22 @@ class ValidateVatNumberJob implements ShouldQueue public int $timeout = 15; public function __construct( - private readonly int $accountVatSettingId, + private readonly int $vatSettingId, private readonly string $vatNumber, ) {} public function handle( - ViesValidationService $viesService, - AccountVatSettingRepositoryInterface $repository, - LoggerInterface $logger, + ViesValidationService $viesService, + OrganizerVatSettingRepositoryInterface $repository, + LoggerInterface $logger, ): void { $logger->info('VAT validation job started', [ - 'account_vat_setting_id' => $this->accountVatSettingId, + 'organizer_vat_setting_id' => $this->vatSettingId, 'vat_number' => $this->maskVatNumber($this->vatNumber), 'attempt' => $this->attempts(), ]); - $repository->updateFromArray($this->accountVatSettingId, [ + $repository->updateFromArray($this->vatSettingId, [ 'vat_validation_status' => VatValidationStatus::VALIDATING->value, 'vat_validation_attempts' => $this->attempts(), ]); @@ -51,13 +51,13 @@ public function handle( if ($result->valid) { $logger->info('VAT validation successful', [ - 'account_vat_setting_id' => $this->accountVatSettingId, + 'organizer_vat_setting_id' => $this->vatSettingId, 'vat_number' => $this->maskVatNumber($this->vatNumber), 'business_name' => $result->businessName, 'attempt' => $this->attempts(), ]); - $repository->updateFromArray($this->accountVatSettingId, [ + $repository->updateFromArray($this->vatSettingId, [ 'vat_validated' => true, 'vat_validation_status' => VatValidationStatus::VALID->value, 'vat_validation_date' => now(), @@ -73,13 +73,13 @@ public function handle( if ($result->isTransientError) { $logger->warning('VAT validation transient error - will retry', [ - 'account_vat_setting_id' => $this->accountVatSettingId, + 'organizer_vat_setting_id' => $this->vatSettingId, 'vat_number' => $this->maskVatNumber($this->vatNumber), 'error' => $result->errorMessage, 'attempt' => $this->attempts(), ]); - $repository->updateFromArray($this->accountVatSettingId, [ + $repository->updateFromArray($this->vatSettingId, [ 'vat_validation_status' => VatValidationStatus::PENDING->value, 'vat_validation_error' => $result->errorMessage, 'vat_validation_attempts' => $this->attempts(), @@ -91,13 +91,13 @@ public function handle( } $logger->info('VAT validation failed - invalid VAT number', [ - 'account_vat_setting_id' => $this->accountVatSettingId, + 'organizer_vat_setting_id' => $this->vatSettingId, 'vat_number' => $this->maskVatNumber($this->vatNumber), 'error' => $result->errorMessage, 'attempt' => $this->attempts(), ]); - $repository->updateFromArray($this->accountVatSettingId, [ + $repository->updateFromArray($this->vatSettingId, [ 'vat_validated' => false, 'vat_validation_status' => VatValidationStatus::INVALID->value, 'vat_validation_error' => $result->errorMessage, @@ -108,17 +108,17 @@ public function handle( public function failed(Throwable $exception): void { $logger = app(LoggerInterface::class); - $repository = app(AccountVatSettingRepositoryInterface::class); + $repository = app(OrganizerVatSettingRepositoryInterface::class); $logger->error('VAT validation job failed permanently', [ - 'account_vat_setting_id' => $this->accountVatSettingId, + 'organizer_vat_setting_id' => $this->vatSettingId, 'vat_number' => $this->maskVatNumber($this->vatNumber), 'error' => $exception->getMessage(), 'attempt' => $this->attempts(), ]); try { - $repository->updateFromArray($this->accountVatSettingId, [ + $repository->updateFromArray($this->vatSettingId, [ 'vat_validated' => false, 'vat_validation_status' => VatValidationStatus::FAILED->value, 'vat_validation_error' => __('Validation failed after multiple attempts: :error', [ @@ -128,7 +128,7 @@ public function failed(Throwable $exception): void ]); } catch (Throwable $e) { $logger->error('Failed to update VAT setting after job failure', [ - 'account_vat_setting_id' => $this->accountVatSettingId, + 'organizer_vat_setting_id' => $this->vatSettingId, 'error' => $e->getMessage(), ]); } @@ -137,21 +137,7 @@ public function failed(Throwable $exception): void public function backoff(): array { return [ - 10, // 10s - 10, // 10s - 10, // 10s - 10, // 10s - 20, // 20s - 30, // 30s - 60, // 1m - 120, // 2m - 180, // 3m - 300, // 5m - 420, // 7m - 600, // 10m - 900, // 15m - 1200, // 20m - 1800, // 30m + 10, 10, 10, 10, 20, 30, 60, 120, 180, 300, 420, 600, 900, 1200, 1800, ]; } diff --git a/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php b/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php index 1c17ce4976..8121f9f513 100644 --- a/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php +++ b/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php @@ -78,6 +78,7 @@ public function handle( direction: CapacityChangeDirection::INCREASED, productId: $productPrice->getProductId(), productPriceId: $entry->getProductPriceId(), + eventOccurrenceId: $entry->getEventOccurrenceId(), )); } catch (Throwable $e) { Log::error('Failed to process expired waitlist offer', [ diff --git a/backend/app/Jobs/Waitlist/SendWaitlistConfirmationEmailJob.php b/backend/app/Jobs/Waitlist/SendWaitlistConfirmationEmailJob.php index f22fdaa9e4..95bc94d262 100644 --- a/backend/app/Jobs/Waitlist/SendWaitlistConfirmationEmailJob.php +++ b/backend/app/Jobs/Waitlist/SendWaitlistConfirmationEmailJob.php @@ -7,6 +7,7 @@ use HiEvents\DomainObjects\WaitlistEntryDomainObject; use HiEvents\Mail\Waitlist\WaitlistConfirmationMail; use HiEvents\Repository\Eloquent\Value\Relationship; +use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; @@ -25,17 +26,15 @@ class SendWaitlistConfirmationEmailJob implements ShouldQueue public function __construct( private readonly WaitlistEntryDomainObject $entry, - ) - { - } + ) {} public function handle( - EventRepositoryInterface $eventRepository, + EventRepositoryInterface $eventRepository, ProductPriceRepositoryInterface $productPriceRepository, - ProductRepositoryInterface $productRepository, - Mailer $mailer, - ): void - { + ProductRepositoryInterface $productRepository, + EventOccurrenceRepositoryInterface $occurrenceRepository, + Mailer $mailer, + ): void { $event = $eventRepository ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) ->loadRelation(new Relationship(EventSettingDomainObject::class)) @@ -48,6 +47,10 @@ public function handle( $product = $productRepository->findById($productPrice->getProductId()); } + $occurrence = $this->entry->getEventOccurrenceId() !== null + ? $occurrenceRepository->findById($this->entry->getEventOccurrenceId()) + : null; + $mailer ->to($this->entry->getEmail()) ->locale($this->entry->getLocale()) @@ -58,6 +61,7 @@ public function handle( productPrice: $productPrice, organizer: $event->getOrganizer(), eventSettings: $event->getEventSettings(), + occurrence: $occurrence, )); } } diff --git a/backend/app/Jobs/Waitlist/SendWaitlistOfferEmailJob.php b/backend/app/Jobs/Waitlist/SendWaitlistOfferEmailJob.php index 31a52696aa..7f64d4d906 100644 --- a/backend/app/Jobs/Waitlist/SendWaitlistOfferEmailJob.php +++ b/backend/app/Jobs/Waitlist/SendWaitlistOfferEmailJob.php @@ -7,6 +7,7 @@ use HiEvents\DomainObjects\WaitlistEntryDomainObject; use HiEvents\Mail\Waitlist\WaitlistOfferMail; use HiEvents\Repository\Eloquent\Value\Relationship; +use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; @@ -25,20 +26,19 @@ class SendWaitlistOfferEmailJob implements ShouldQueue public function __construct( private readonly WaitlistEntryDomainObject $entry, - private readonly string $orderShortId, - private readonly string $sessionIdentifier, - ) - { + private readonly string $orderShortId, + private readonly string $sessionIdentifier, + ) { $this->afterCommit = true; } public function handle( - EventRepositoryInterface $eventRepository, + EventRepositoryInterface $eventRepository, ProductPriceRepositoryInterface $productPriceRepository, - ProductRepositoryInterface $productRepository, - Mailer $mailer, - ): void - { + ProductRepositoryInterface $productRepository, + EventOccurrenceRepositoryInterface $occurrenceRepository, + Mailer $mailer, + ): void { $event = $eventRepository ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) ->loadRelation(new Relationship(EventSettingDomainObject::class)) @@ -51,6 +51,10 @@ public function handle( $product = $productRepository->findById($productPrice->getProductId()); } + $occurrence = $this->entry->getEventOccurrenceId() !== null + ? $occurrenceRepository->findById($this->entry->getEventOccurrenceId()) + : null; + $mailer ->to($this->entry->getEmail()) ->locale($this->entry->getLocale()) @@ -63,6 +67,7 @@ public function handle( eventSettings: $event->getEventSettings(), orderShortId: $this->orderShortId, sessionIdentifier: $this->sessionIdentifier, + occurrence: $occurrence, )); } } diff --git a/backend/app/Jobs/Waitlist/SendWaitlistOfferExpiredEmailJob.php b/backend/app/Jobs/Waitlist/SendWaitlistOfferExpiredEmailJob.php index c47b3f63de..8bf0f3fb6f 100644 --- a/backend/app/Jobs/Waitlist/SendWaitlistOfferExpiredEmailJob.php +++ b/backend/app/Jobs/Waitlist/SendWaitlistOfferExpiredEmailJob.php @@ -7,6 +7,7 @@ use HiEvents\DomainObjects\WaitlistEntryDomainObject; use HiEvents\Mail\Waitlist\WaitlistOfferExpiredMail; use HiEvents\Repository\Eloquent\Value\Relationship; +use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; @@ -25,17 +26,15 @@ class SendWaitlistOfferExpiredEmailJob implements ShouldQueue public function __construct( private readonly WaitlistEntryDomainObject $entry, - ) - { - } + ) {} public function handle( - EventRepositoryInterface $eventRepository, + EventRepositoryInterface $eventRepository, ProductPriceRepositoryInterface $productPriceRepository, - ProductRepositoryInterface $productRepository, - Mailer $mailer, - ): void - { + ProductRepositoryInterface $productRepository, + EventOccurrenceRepositoryInterface $occurrenceRepository, + Mailer $mailer, + ): void { $event = $eventRepository ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) ->loadRelation(new Relationship(EventSettingDomainObject::class)) @@ -48,6 +47,10 @@ public function handle( $product = $productRepository->findById($productPrice->getProductId()); } + $occurrence = $this->entry->getEventOccurrenceId() !== null + ? $occurrenceRepository->findById($this->entry->getEventOccurrenceId()) + : null; + $mailer ->to($this->entry->getEmail()) ->locale($this->entry->getLocale()) @@ -58,6 +61,7 @@ public function handle( productPrice: $productPrice, organizer: $event->getOrganizer(), eventSettings: $event->getEventSettings(), + occurrence: $occurrence, )); } } diff --git a/backend/app/Listeners/Occurrence/RefundOccurrenceOrdersListener.php b/backend/app/Listeners/Occurrence/RefundOccurrenceOrdersListener.php new file mode 100644 index 0000000000..bfc0269f0a --- /dev/null +++ b/backend/app/Listeners/Occurrence/RefundOccurrenceOrdersListener.php @@ -0,0 +1,21 @@ +refundOrders) { + return; + } + + dispatch(new RefundOccurrenceOrdersJob( + eventId: $event->eventId, + occurrenceId: $event->occurrenceId, + )); + } +} diff --git a/backend/app/Listeners/Occurrence/SendOccurrenceCancellationNotification.php b/backend/app/Listeners/Occurrence/SendOccurrenceCancellationNotification.php new file mode 100644 index 0000000000..28686094dc --- /dev/null +++ b/backend/app/Listeners/Occurrence/SendOccurrenceCancellationNotification.php @@ -0,0 +1,18 @@ +eventId, + occurrenceId: $event->occurrenceId, + refundOrders: $event->refundOrders, + )); + } +} diff --git a/backend/app/Listeners/Waitlist/CancelWaitlistEntriesOnOccurrenceCancelledListener.php b/backend/app/Listeners/Waitlist/CancelWaitlistEntriesOnOccurrenceCancelledListener.php new file mode 100644 index 0000000000..fbc2ad4979 --- /dev/null +++ b/backend/app/Listeners/Waitlist/CancelWaitlistEntriesOnOccurrenceCancelledListener.php @@ -0,0 +1,40 @@ +waitlistEntryRepository->updateWhere( + attributes: [ + 'status' => WaitlistEntryStatus::CANCELLED->name, + 'cancelled_at' => now(), + ], + where: [ + 'event_id' => $event->eventId, + 'event_occurrence_id' => $event->occurrenceId, + ['status', 'in', [ + WaitlistEntryStatus::WAITING->name, + WaitlistEntryStatus::OFFERED->name, + ]], + ], + ); + } +} diff --git a/backend/app/Listeners/Waitlist/ProcessWaitlistOnCapacityAvailableListener.php b/backend/app/Listeners/Waitlist/ProcessWaitlistOnCapacityAvailableListener.php index 6ee1809634..8af51276d7 100644 --- a/backend/app/Listeners/Waitlist/ProcessWaitlistOnCapacityAvailableListener.php +++ b/backend/app/Listeners/Waitlist/ProcessWaitlistOnCapacityAvailableListener.php @@ -11,16 +11,16 @@ use HiEvents\Services\Domain\Product\AvailableProductQuantitiesFetchService; use HiEvents\Services\Domain\Waitlist\ProcessWaitlistService; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Support\Facades\Log; +use Throwable; class ProcessWaitlistOnCapacityAvailableListener implements ShouldQueue { public function __construct( - private readonly EventRepositoryInterface $eventRepository, - private readonly ProcessWaitlistService $processWaitlistService, + private readonly EventRepositoryInterface $eventRepository, + private readonly ProcessWaitlistService $processWaitlistService, private readonly AvailableProductQuantitiesFetchService $availableQuantitiesService, - ) - { - } + ) {} public function handle(CapacityChangedEvent $event): void { @@ -34,13 +34,14 @@ public function handle(CapacityChangedEvent $event): void $eventSettings = $eventDomainObject->getEventSettings(); - if (!$eventSettings?->getWaitlistAutoProcess()) { + if (! $eventSettings?->getWaitlistAutoProcess()) { return; } $quantities = $this->availableQuantitiesService->getAvailableProductQuantities( $event->eventId, ignoreCache: true, + eventOccurrenceId: $event->eventOccurrenceId, ); foreach ($quantities->productQuantities as $productQuantity) { @@ -60,9 +61,20 @@ public function handle(CapacityChangedEvent $event): void quantity: $availableCount, event: $eventDomainObject, eventSettings: $eventSettings, + eventOccurrenceId: $event->eventOccurrenceId, ); } catch (NoCapacityAvailableException) { // Expected: no waiting entries or capacity consumed by pending offers + } catch (Throwable $e) { + // Unexpected — surface it (otherwise the listener silently fails and + // waitlist entries stall). Re-throw so the queue retries the listener. + Log::error('ProcessWaitlistOnCapacityAvailableListener failed', [ + 'event_id' => $event->eventId, + 'product_id' => $event->productId, + 'price_id' => $productQuantity->price_id, + 'error' => $e->getMessage(), + ]); + throw $e; } } } diff --git a/backend/app/Listeners/Waitlist/ResolveWaitlistEntryOnOrderCompletedListener.php b/backend/app/Listeners/Waitlist/ResolveWaitlistEntryOnOrderCompletedListener.php index 4dcf45554a..21e98a25fb 100644 --- a/backend/app/Listeners/Waitlist/ResolveWaitlistEntryOnOrderCompletedListener.php +++ b/backend/app/Listeners/Waitlist/ResolveWaitlistEntryOnOrderCompletedListener.php @@ -70,6 +70,7 @@ private function revertOfferedEntriesByOrderId(int $orderId): void direction: CapacityChangeDirection::INCREASED, productId: $productPrice->getProductId(), productPriceId: $entry->getProductPriceId(), + eventOccurrenceId: $entry->getEventOccurrenceId(), ); } }); diff --git a/backend/app/Listeners/Webhook/WebhookEventListener.php b/backend/app/Listeners/Webhook/WebhookEventListener.php index c3f93cfd43..a475a25c90 100644 --- a/backend/app/Listeners/Webhook/WebhookEventListener.php +++ b/backend/app/Listeners/Webhook/WebhookEventListener.php @@ -4,11 +4,13 @@ use HiEvents\Jobs\Order\Webhook\DispatchAttendeeWebhookJob; use HiEvents\Jobs\Order\Webhook\DispatchCheckInWebhookJob; +use HiEvents\Jobs\Order\Webhook\DispatchOccurrenceWebhookJob; use HiEvents\Jobs\Order\Webhook\DispatchOrderWebhookJob; use HiEvents\Jobs\Order\Webhook\DispatchProductWebhookJob; use HiEvents\Services\Infrastructure\DomainEvents\Events\AttendeeEvent; use HiEvents\Services\Infrastructure\DomainEvents\Events\BaseDomainEvent; use HiEvents\Services\Infrastructure\DomainEvents\Events\CheckinEvent; +use HiEvents\Services\Infrastructure\DomainEvents\Events\OccurrenceEvent; use HiEvents\Services\Infrastructure\DomainEvents\Events\OrderEvent; use HiEvents\Services\Infrastructure\DomainEvents\Events\ProductEvent; use Illuminate\Config\Repository; @@ -50,6 +52,12 @@ public function handle(BaseDomainEvent $event): void eventType: $event->type, )->onQueue($queueName); break; + case OccurrenceEvent::class: + DispatchOccurrenceWebhookJob::dispatch( + occurrenceId: $event->occurrenceId, + eventType: $event->type, + )->onQueue($queueName); + break; } } } diff --git a/backend/app/Mail/Attendee/AttendeeTicketMail.php b/backend/app/Mail/Attendee/AttendeeTicketMail.php index 46ae3d8abc..5fb72d4357 100644 --- a/backend/app/Mail/Attendee/AttendeeTicketMail.php +++ b/backend/app/Mail/Attendee/AttendeeTicketMail.php @@ -5,6 +5,7 @@ use Carbon\Carbon; use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; @@ -33,6 +34,7 @@ public function __construct( private readonly EventSettingDomainObject $eventSettings, private readonly OrganizerDomainObject $organizer, ?RenderedEmailTemplateDTO $renderedTemplate = null, + private readonly ?EventOccurrenceDomainObject $occurrence = null, ) { parent::__construct(); @@ -73,6 +75,7 @@ public function content(): Content 'eventSettings' => $this->eventSettings, 'organizer' => $this->organizer, 'order' => $this->order, + 'occurrence' => $this->occurrence ?? $this->attendee->getEventOccurrence(), 'ticketUrl' => sprintf( Url::getFrontEndUrlFromConfig(Url::ATTENDEE_TICKET), $this->event->getId(), @@ -84,11 +87,23 @@ public function content(): Content public function attachments(): array { - $startDateTime = Carbon::parse($this->event->getStartDate(), $this->event->getTimezone()); - $endDateTime = $this->event->getEndDate() ? Carbon::parse($this->event->getEndDate(), $this->event->getTimezone()) : null; + $startDateRaw = $this->occurrence?->getStartDate() ?? $this->event->getStartDate(); + $endDateRaw = $this->occurrence?->getEndDate() ?? $this->event->getEndDate(); + + $startDateTime = $startDateRaw ? Carbon::parse($startDateRaw, $this->event->getTimezone()) : null; + $endDateTime = $endDateRaw ? Carbon::parse($endDateRaw, $this->event->getTimezone()) : null; + + if ($startDateTime === null) { + return []; + } + + $eventTitle = $this->event->getTitle(); + if ($this->occurrence?->getLabel()) { + $eventTitle .= ' - ' . $this->occurrence->getLabel(); + } $event = Event::create() - ->name($this->event->getTitle()) + ->name($eventTitle) ->uniqueIdentifier('event-' . $this->attendee->getId()) ->startsAt($startDateTime) ->url($this->event->getEventUrl()) diff --git a/backend/app/Mail/Occurrence/OccurrenceCancellationMail.php b/backend/app/Mail/Occurrence/OccurrenceCancellationMail.php new file mode 100644 index 0000000000..c5587ade6b --- /dev/null +++ b/backend/app/Mail/Occurrence/OccurrenceCancellationMail.php @@ -0,0 +1,76 @@ +renderedTemplate = $renderedTemplate; + parent::__construct(); + } + + public function envelope(): Envelope + { + $subject = $this->renderedTemplate?->subject ?? __(':event on :date has been cancelled', [ + 'event' => $this->event->getTitle(), + 'date' => $this->formattedDate, + ]); + + return new Envelope( + replyTo: $this->eventSettings->getSupportEmail(), + subject: $subject, + ); + } + + public function content(): Content + { + if ($this->renderedTemplate) { + return new Content( + markdown: 'emails.custom-template', + with: [ + 'renderedBody' => $this->renderedTemplate->body, + 'renderedCta' => $this->renderedTemplate->cta, + 'eventSettings' => $this->eventSettings, + ] + ); + } + + return new Content( + markdown: 'emails.occurrence.cancellation', + with: [ + 'event' => $this->event, + 'occurrence' => $this->occurrence, + 'organizer' => $this->organizer, + 'eventSettings' => $this->eventSettings, + 'formattedDate' => $this->formattedDate, + 'refundOrders' => $this->refundOrders, + 'eventUrl' => sprintf( + Url::getFrontEndUrlFromConfig(Url::EVENT_HOMEPAGE), + $this->event->getId(), + $this->event->getSlug(), + ), + ] + ); + } +} diff --git a/backend/app/Mail/Order/OrderSummary.php b/backend/app/Mail/Order/OrderSummary.php index 4e6f1b838d..310a812a8e 100644 --- a/backend/app/Mail/Order/OrderSummary.php +++ b/backend/app/Mail/Order/OrderSummary.php @@ -4,6 +4,7 @@ use Barryvdh\DomPDF\Facade\Pdf; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\InvoiceDomainObject; use HiEvents\DomainObjects\OrderDomainObject; @@ -23,12 +24,13 @@ class OrderSummary extends BaseMail private readonly ?RenderedEmailTemplateDTO $renderedTemplate; public function __construct( - private readonly OrderDomainObject $order, - private readonly EventDomainObject $event, - private readonly OrganizerDomainObject $organizer, - private readonly EventSettingDomainObject $eventSettings, - private readonly ?InvoiceDomainObject $invoice, - ?RenderedEmailTemplateDTO $renderedTemplate = null, + private readonly OrderDomainObject $order, + private readonly EventDomainObject $event, + private readonly OrganizerDomainObject $organizer, + private readonly EventSettingDomainObject $eventSettings, + private readonly ?InvoiceDomainObject $invoice, + private readonly ?EventOccurrenceDomainObject $occurrence = null, + ?RenderedEmailTemplateDTO $renderedTemplate = null, ) { $this->renderedTemplate = $renderedTemplate; @@ -67,6 +69,7 @@ public function content(): Content 'event' => $this->event, 'order' => $this->order, 'organizer' => $this->organizer, + 'occurrence' => $this->occurrence, 'orderUrl' => sprintf( Url::getFrontEndUrlFromConfig(Url::ORDER_SUMMARY), $this->event->getId(), diff --git a/backend/app/Mail/Waitlist/WaitlistConfirmationMail.php b/backend/app/Mail/Waitlist/WaitlistConfirmationMail.php index 1a29619cd4..22d33476a8 100644 --- a/backend/app/Mail/Waitlist/WaitlistConfirmationMail.php +++ b/backend/app/Mail/Waitlist/WaitlistConfirmationMail.php @@ -2,7 +2,9 @@ namespace HiEvents\Mail\Waitlist; +use Carbon\Carbon; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\ProductDomainObject; @@ -16,14 +18,14 @@ class WaitlistConfirmationMail extends BaseMail { public function __construct( - private readonly WaitlistEntryDomainObject $entry, - private readonly EventDomainObject $event, - private readonly ?ProductDomainObject $product, - private readonly ?ProductPriceDomainObject $productPrice, - private readonly OrganizerDomainObject $organizer, - private readonly EventSettingDomainObject $eventSettings, - ) - { + private readonly WaitlistEntryDomainObject $entry, + private readonly EventDomainObject $event, + private readonly ?ProductDomainObject $product, + private readonly ?ProductPriceDomainObject $productPrice, + private readonly OrganizerDomainObject $organizer, + private readonly EventSettingDomainObject $eventSettings, + private readonly ?EventOccurrenceDomainObject $occurrence = null, + ) { parent::__construct(); } @@ -43,6 +45,7 @@ public function content(): Content 'entry' => $this->entry, 'event' => $this->event, 'productName' => $this->buildProductName(), + 'occurrenceDateFormatted' => $this->formatOccurrenceDate(), 'organizer' => $this->organizer, 'eventSettings' => $this->eventSettings, 'eventUrl' => sprintf( @@ -54,16 +57,27 @@ public function content(): Content ); } + private function formatOccurrenceDate(): ?string + { + if ($this->occurrence === null) { + return null; + } + + return Carbon::parse($this->occurrence->getStartDate(), 'UTC') + ->setTimezone($this->event->getTimezone()) + ->isoFormat('dddd, MMMM D · h:mm A'); + } + private function buildProductName(): ?string { - if (!$this->product) { + if (! $this->product) { return null; } $name = $this->product->getTitle(); if ($this->productPrice?->getLabel()) { - $name .= ' - ' . $this->productPrice->getLabel(); + $name .= ' - '.$this->productPrice->getLabel(); } return $name; diff --git a/backend/app/Mail/Waitlist/WaitlistOfferExpiredMail.php b/backend/app/Mail/Waitlist/WaitlistOfferExpiredMail.php index 9d52672572..fe88042321 100644 --- a/backend/app/Mail/Waitlist/WaitlistOfferExpiredMail.php +++ b/backend/app/Mail/Waitlist/WaitlistOfferExpiredMail.php @@ -2,7 +2,9 @@ namespace HiEvents\Mail\Waitlist; +use Carbon\Carbon; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\ProductDomainObject; @@ -16,14 +18,14 @@ class WaitlistOfferExpiredMail extends BaseMail { public function __construct( - private readonly WaitlistEntryDomainObject $entry, - private readonly EventDomainObject $event, - private readonly ?ProductDomainObject $product, - private readonly ?ProductPriceDomainObject $productPrice, - private readonly OrganizerDomainObject $organizer, - private readonly EventSettingDomainObject $eventSettings, - ) - { + private readonly WaitlistEntryDomainObject $entry, + private readonly EventDomainObject $event, + private readonly ?ProductDomainObject $product, + private readonly ?ProductPriceDomainObject $productPrice, + private readonly OrganizerDomainObject $organizer, + private readonly EventSettingDomainObject $eventSettings, + private readonly ?EventOccurrenceDomainObject $occurrence = null, + ) { parent::__construct(); } @@ -43,27 +45,39 @@ public function content(): Content 'entry' => $this->entry, 'event' => $this->event, 'productName' => $this->buildProductName(), + 'occurrenceDateFormatted' => $this->formatOccurrenceDate(), 'organizer' => $this->organizer, 'eventSettings' => $this->eventSettings, 'eventUrl' => sprintf( Url::getFrontEndUrlFromConfig(Url::EVENT_HOMEPAGE), $this->event->getId(), $this->event->getSlug(), - ) . '?clear_waitlist=true', + ).'?clear_waitlist=true', ] ); } + private function formatOccurrenceDate(): ?string + { + if ($this->occurrence === null) { + return null; + } + + return Carbon::parse($this->occurrence->getStartDate(), 'UTC') + ->setTimezone($this->event->getTimezone()) + ->isoFormat('dddd, MMMM D · h:mm A'); + } + private function buildProductName(): ?string { - if (!$this->product) { + if (! $this->product) { return null; } $name = $this->product->getTitle(); if ($this->productPrice?->getLabel()) { - $name .= ' - ' . $this->productPrice->getLabel(); + $name .= ' - '.$this->productPrice->getLabel(); } return $name; diff --git a/backend/app/Mail/Waitlist/WaitlistOfferMail.php b/backend/app/Mail/Waitlist/WaitlistOfferMail.php index 8cd81e2682..45f5bc1522 100644 --- a/backend/app/Mail/Waitlist/WaitlistOfferMail.php +++ b/backend/app/Mail/Waitlist/WaitlistOfferMail.php @@ -4,6 +4,7 @@ use Carbon\Carbon; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\ProductDomainObject; @@ -17,16 +18,16 @@ class WaitlistOfferMail extends BaseMail { public function __construct( - private readonly WaitlistEntryDomainObject $entry, - private readonly EventDomainObject $event, - private readonly ?ProductDomainObject $product, - private readonly ?ProductPriceDomainObject $productPrice, - private readonly OrganizerDomainObject $organizer, - private readonly EventSettingDomainObject $eventSettings, - private readonly string $orderShortId, - private readonly string $sessionIdentifier, - ) - { + private readonly WaitlistEntryDomainObject $entry, + private readonly EventDomainObject $event, + private readonly ?ProductDomainObject $product, + private readonly ?ProductPriceDomainObject $productPrice, + private readonly OrganizerDomainObject $organizer, + private readonly EventSettingDomainObject $eventSettings, + private readonly string $orderShortId, + private readonly string $sessionIdentifier, + private readonly ?EventOccurrenceDomainObject $occurrence = null, + ) { parent::__construct(); } @@ -46,6 +47,7 @@ public function content(): Content 'entry' => $this->entry, 'event' => $this->event, 'productName' => $this->buildProductName(), + 'occurrenceDateFormatted' => $this->formatOccurrenceDate(), 'organizer' => $this->organizer, 'eventSettings' => $this->eventSettings, 'offerExpiresAtFormatted' => $this->formatOfferExpiry(), @@ -72,16 +74,27 @@ private function formatOfferExpiry(): ?string return Carbon::parse($expiresAt)->isoFormat('MMMM D, YYYY [at] h:mm A (z)'); } + private function formatOccurrenceDate(): ?string + { + if ($this->occurrence === null) { + return null; + } + + return Carbon::parse($this->occurrence->getStartDate(), 'UTC') + ->setTimezone($this->event->getTimezone()) + ->isoFormat('dddd, MMMM D · h:mm A'); + } + private function buildProductName(): ?string { - if (!$this->product) { + if (! $this->product) { return null; } $name = $this->product->getTitle(); if ($this->productPrice?->getLabel()) { - $name .= ' - ' . $this->productPrice->getLabel(); + $name .= ' - '.$this->productPrice->getLabel(); } return $name; diff --git a/backend/app/Models/Account.php b/backend/app/Models/Account.php index 9d470392bd..4bf14ddf4a 100644 --- a/backend/app/Models/Account.php +++ b/backend/app/Models/Account.php @@ -34,6 +34,11 @@ public function events(): HasMany return $this->hasMany(Event::class); } + public function organizers(): HasMany + { + return $this->hasMany(Organizer::class); + } + public function configuration(): BelongsTo { return $this->belongsTo( diff --git a/backend/app/Models/Attendee.php b/backend/app/Models/Attendee.php index 9888de64c4..e630c04965 100644 --- a/backend/app/Models/Attendee.php +++ b/backend/app/Models/Attendee.php @@ -28,6 +28,11 @@ public function product(): BelongsTo return $this->belongsTo(Product::class); } + public function event_occurrence(): BelongsTo + { + return $this->belongsTo(EventOccurrence::class, 'event_occurrence_id'); + } + public function check_ins(): HasMany { return $this->hasMany(AttendeeCheckIn::class); diff --git a/backend/app/Models/CheckInList.php b/backend/app/Models/CheckInList.php index 0004d4bd35..70f1f02b76 100644 --- a/backend/app/Models/CheckInList.php +++ b/backend/app/Models/CheckInList.php @@ -22,4 +22,9 @@ public function event(): BelongsTo { return $this->belongsTo(Event::class); } + + public function event_occurrence(): BelongsTo + { + return $this->belongsTo(EventOccurrence::class, 'event_occurrence_id'); + } } diff --git a/backend/app/Models/Event.php b/backend/app/Models/Event.php index 1d4741a68f..3d4ec316a9 100644 --- a/backend/app/Models/Event.php +++ b/backend/app/Models/Event.php @@ -81,6 +81,11 @@ public function affiliates(): HasMany return $this->hasMany(Affiliate::class); } + public function event_occurrences(): HasMany + { + return $this->hasMany(EventOccurrence::class); + } + public static function boot(): void { parent::boot(); @@ -96,10 +101,9 @@ static function (Event $event) { protected function getCastMap(): array { return [ - EventDomainObjectAbstract::START_DATE => 'datetime', - EventDomainObjectAbstract::END_DATE => 'datetime', EventDomainObjectAbstract::ATTRIBUTES => 'array', EventDomainObjectAbstract::LOCATION_DETAILS => 'array', + EventDomainObjectAbstract::RECURRENCE_RULE => 'array', ]; } } diff --git a/backend/app/Models/EventOccurrence.php b/backend/app/Models/EventOccurrence.php new file mode 100644 index 0000000000..469261d175 --- /dev/null +++ b/backend/app/Models/EventOccurrence.php @@ -0,0 +1,66 @@ +belongsTo(Event::class); + } + + public function order_items(): HasMany + { + return $this->hasMany(OrderItem::class, 'event_occurrence_id'); + } + + public function attendees(): HasMany + { + return $this->hasMany(Attendee::class, 'event_occurrence_id'); + } + + public function check_in_lists(): HasMany + { + return $this->hasMany(CheckInList::class, 'event_occurrence_id'); + } + + public function price_overrides(): HasMany + { + return $this->hasMany(ProductPriceOccurrenceOverride::class, 'event_occurrence_id'); + } + + public function event_occurrence_statistics(): HasOne + { + return $this->hasOne(EventOccurrenceStatistic::class, 'event_occurrence_id'); + } + + public function product_occurrence_visibility(): HasMany + { + return $this->hasMany(ProductOccurrenceVisibility::class, 'event_occurrence_id'); + } + + public function event_occurrence_daily_statistics(): HasMany + { + return $this->hasMany(EventOccurrenceDailyStatistic::class, 'event_occurrence_id'); + } + + protected function getCastMap(): array + { + return [ + 'start_date' => 'datetime', + 'end_date' => 'datetime', + 'is_overridden' => 'boolean', + ]; + } +} diff --git a/backend/app/Models/EventOccurrenceDailyStatistic.php b/backend/app/Models/EventOccurrenceDailyStatistic.php new file mode 100644 index 0000000000..bc09c9bdc5 --- /dev/null +++ b/backend/app/Models/EventOccurrenceDailyStatistic.php @@ -0,0 +1,21 @@ + 'float', + 'total_fee' => 'float', + 'sales_total_gross' => 'float', + 'sales_total_before_additions' => 'float', + 'total_refunded' => 'float', + ]; + } +} diff --git a/backend/app/Models/EventOccurrenceStatistic.php b/backend/app/Models/EventOccurrenceStatistic.php new file mode 100644 index 0000000000..0f898be680 --- /dev/null +++ b/backend/app/Models/EventOccurrenceStatistic.php @@ -0,0 +1,21 @@ + 'float', + 'total_fee' => 'float', + 'sales_total_before_additions' => 'float', + 'sales_total_gross' => 'float', + 'total_refunded' => 'float', + ]; + } +} diff --git a/backend/app/Models/Message.php b/backend/app/Models/Message.php index 6f29ec7bff..d0e264cb92 100644 --- a/backend/app/Models/Message.php +++ b/backend/app/Models/Message.php @@ -2,6 +2,7 @@ namespace HiEvents\Models; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; @@ -20,6 +21,11 @@ public function outgoing_messages(): HasMany return $this->hasMany(OutgoingMessage::class); } + public function event_occurrence(): BelongsTo + { + return $this->belongsTo(EventOccurrence::class); + } + protected function getCastMap(): array { return [ diff --git a/backend/app/Models/OrderItem.php b/backend/app/Models/OrderItem.php index f9a2d3630c..32472f38f5 100644 --- a/backend/app/Models/OrderItem.php +++ b/backend/app/Models/OrderItem.php @@ -43,4 +43,9 @@ public function product(): BelongsTo { return $this->belongsTo(Product::class); } + + public function event_occurrence(): BelongsTo + { + return $this->belongsTo(EventOccurrence::class, 'event_occurrence_id'); + } } diff --git a/backend/app/Models/Organizer.php b/backend/app/Models/Organizer.php index 9eb57f2bdc..5488bac90e 100644 --- a/backend/app/Models/Organizer.php +++ b/backend/app/Models/Organizer.php @@ -3,6 +3,7 @@ namespace HiEvents\Models; use HiEvents\Models\Traits\HasImages; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; @@ -26,4 +27,19 @@ public function webhooks(): HasMany { return $this->hasMany(Webhook::class); } + + public function organizer_stripe_platforms(): HasMany + { + return $this->hasMany(OrganizerStripePlatform::class); + } + + public function organizer_vat_setting(): HasOne + { + return $this->hasOne(OrganizerVatSetting::class); + } + + public function organizer_configuration(): BelongsTo + { + return $this->belongsTo(OrganizerConfiguration::class, 'organizer_configuration_id'); + } } diff --git a/backend/app/Models/OrganizerConfiguration.php b/backend/app/Models/OrganizerConfiguration.php new file mode 100644 index 0000000000..6285f04845 --- /dev/null +++ b/backend/app/Models/OrganizerConfiguration.php @@ -0,0 +1,23 @@ + 'array', + ]; + } + + public function organizers(): HasMany + { + return $this->hasMany(Organizer::class); + } +} diff --git a/backend/app/Models/OrganizerStripePlatform.php b/backend/app/Models/OrganizerStripePlatform.php new file mode 100644 index 0000000000..b0a3032168 --- /dev/null +++ b/backend/app/Models/OrganizerStripePlatform.php @@ -0,0 +1,31 @@ + 'array', + 'stripe_setup_completed_at' => 'datetime', + ]; + } + + public function organizer(): BelongsTo + { + return $this->belongsTo(Organizer::class); + } +} diff --git a/backend/app/Models/OrganizerVatSetting.php b/backend/app/Models/OrganizerVatSetting.php new file mode 100644 index 0000000000..77709c8510 --- /dev/null +++ b/backend/app/Models/OrganizerVatSetting.php @@ -0,0 +1,61 @@ + 'boolean', + 'vat_validated' => 'boolean', + 'vat_validation_attempts' => 'integer', + 'vat_validation_date' => 'datetime', + ]; + } + + public function organizer(): BelongsTo + { + return $this->belongsTo(Organizer::class); + } +} diff --git a/backend/app/Models/ProductOccurrenceVisibility.php b/backend/app/Models/ProductOccurrenceVisibility.php new file mode 100644 index 0000000000..7c09d0bad1 --- /dev/null +++ b/backend/app/Models/ProductOccurrenceVisibility.php @@ -0,0 +1,27 @@ +belongsTo(EventOccurrence::class, 'event_occurrence_id'); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + protected function getTimestampsEnabled(): bool + { + return false; + } +} diff --git a/backend/app/Models/ProductPriceOccurrenceOverride.php b/backend/app/Models/ProductPriceOccurrenceOverride.php new file mode 100644 index 0000000000..79ba65b942 --- /dev/null +++ b/backend/app/Models/ProductPriceOccurrenceOverride.php @@ -0,0 +1,30 @@ +belongsTo(EventOccurrence::class, 'event_occurrence_id'); + } + + public function product_price(): BelongsTo + { + return $this->belongsTo(ProductPrice::class, 'product_price_id'); + } + + protected function getCastMap(): array + { + return [ + 'price' => 'float', + ]; + } +} diff --git a/backend/app/Models/WaitlistEntry.php b/backend/app/Models/WaitlistEntry.php index 45f07495d9..1321f56151 100644 --- a/backend/app/Models/WaitlistEntry.php +++ b/backend/app/Models/WaitlistEntry.php @@ -21,6 +21,11 @@ public function product_price(): BelongsTo return $this->belongsTo(ProductPrice::class); } + public function event_occurrence(): BelongsTo + { + return $this->belongsTo(EventOccurrence::class); + } + public function order(): BelongsTo { return $this->belongsTo(Order::class); diff --git a/backend/app/Providers/EventServiceProvider.php b/backend/app/Providers/EventServiceProvider.php index 9d94df60a2..88fca68136 100644 --- a/backend/app/Providers/EventServiceProvider.php +++ b/backend/app/Providers/EventServiceProvider.php @@ -5,6 +5,7 @@ use HiEvents\Listeners\Webhook\WebhookEventListener; use HiEvents\Services\Infrastructure\DomainEvents\Events\AttendeeEvent; use HiEvents\Services\Infrastructure\DomainEvents\Events\CheckinEvent; +use HiEvents\Services\Infrastructure\DomainEvents\Events\OccurrenceEvent; use HiEvents\Services\Infrastructure\DomainEvents\Events\OrderEvent; use HiEvents\Services\Infrastructure\DomainEvents\Events\ProductEvent; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; @@ -12,6 +13,9 @@ class EventServiceProvider extends ServiceProvider { + protected $listen = [ + ]; + /** * Map of listeners to the events they should handle. * @@ -23,6 +27,7 @@ class EventServiceProvider extends ServiceProvider OrderEvent::class, AttendeeEvent::class, CheckinEvent::class, + OccurrenceEvent::class, ], ]; diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php index 55f77ef5b6..750af972a4 100644 --- a/backend/app/Providers/RepositoryServiceProvider.php +++ b/backend/app/Providers/RepositoryServiceProvider.php @@ -8,9 +8,7 @@ use HiEvents\Repository\Eloquent\AccountConfigurationRepository; use HiEvents\Repository\Eloquent\AccountMessagingTierRepository; use HiEvents\Repository\Eloquent\AccountRepository; -use HiEvents\Repository\Eloquent\AccountStripePlatformRepository; use HiEvents\Repository\Eloquent\AccountUserRepository; -use HiEvents\Repository\Eloquent\AccountVatSettingRepository; use HiEvents\Repository\Eloquent\AffiliateRepository; use HiEvents\Repository\Eloquent\AttendeeCheckInRepository; use HiEvents\Repository\Eloquent\AttendeeRepository; @@ -18,6 +16,9 @@ use HiEvents\Repository\Eloquent\CheckInListRepository; use HiEvents\Repository\Eloquent\EmailTemplateRepository; use HiEvents\Repository\Eloquent\EventDailyStatisticRepository; +use HiEvents\Repository\Eloquent\EventOccurrenceRepository; +use HiEvents\Repository\Eloquent\EventOccurrenceDailyStatisticRepository; +use HiEvents\Repository\Eloquent\EventOccurrenceStatisticRepository; use HiEvents\Repository\Eloquent\EventRepository; use HiEvents\Repository\Eloquent\EventSettingsRepository; use HiEvents\Repository\Eloquent\EventStatisticRepository; @@ -32,10 +33,15 @@ use HiEvents\Repository\Eloquent\OrderRepository; use HiEvents\Repository\Eloquent\OrganizerRepository; use HiEvents\Repository\Eloquent\OrganizerSettingsRepository; +use HiEvents\Repository\Eloquent\OrganizerStripePlatformRepository; +use HiEvents\Repository\Eloquent\OrganizerVatSettingRepository; +use HiEvents\Repository\Eloquent\OrganizerConfigurationRepository; use HiEvents\Repository\Eloquent\OutgoingMessageRepository; use HiEvents\Repository\Eloquent\PasswordResetRepository; use HiEvents\Repository\Eloquent\PasswordResetTokenRepository; use HiEvents\Repository\Eloquent\ProductCategoryRepository; +use HiEvents\Repository\Eloquent\ProductOccurrenceVisibilityRepository; +use HiEvents\Repository\Eloquent\ProductPriceOccurrenceOverrideRepository; use HiEvents\Repository\Eloquent\ProductPriceRepository; use HiEvents\Repository\Eloquent\ProductRepository; use HiEvents\Repository\Eloquent\PromoCodeRepository; @@ -55,9 +61,7 @@ use HiEvents\Repository\Interfaces\AccountConfigurationRepositoryInterface; use HiEvents\Repository\Interfaces\AccountMessagingTierRepositoryInterface; use HiEvents\Repository\Interfaces\AccountRepositoryInterface; -use HiEvents\Repository\Interfaces\AccountStripePlatformRepositoryInterface; use HiEvents\Repository\Interfaces\AccountUserRepositoryInterface; -use HiEvents\Repository\Interfaces\AccountVatSettingRepositoryInterface; use HiEvents\Repository\Interfaces\AffiliateRepositoryInterface; use HiEvents\Repository\Interfaces\AttendeeCheckInRepositoryInterface; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; @@ -65,6 +69,9 @@ use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface; use HiEvents\Repository\Interfaces\EmailTemplateRepositoryInterface; use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceDailyStatisticRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface; use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface; @@ -79,10 +86,15 @@ use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; use HiEvents\Repository\Interfaces\OrganizerSettingsRepositoryInterface; +use HiEvents\Repository\Interfaces\OrganizerStripePlatformRepositoryInterface; +use HiEvents\Repository\Interfaces\OrganizerVatSettingRepositoryInterface; +use HiEvents\Repository\Interfaces\OrganizerConfigurationRepositoryInterface; use HiEvents\Repository\Interfaces\OutgoingMessageRepositoryInterface; use HiEvents\Repository\Interfaces\PasswordResetRepositoryInterface; use HiEvents\Repository\Interfaces\PasswordResetTokenRepositoryInterface; use HiEvents\Repository\Interfaces\ProductCategoryRepositoryInterface; +use HiEvents\Repository\Interfaces\ProductOccurrenceVisibilityRepositoryInterface; +use HiEvents\Repository\Interfaces\ProductPriceOccurrenceOverrideRepositoryInterface; use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; @@ -148,11 +160,17 @@ class RepositoryServiceProvider extends ServiceProvider OutgoingMessageRepositoryInterface::class => OutgoingMessageRepository::class, OrganizerSettingsRepositoryInterface::class => OrganizerSettingsRepository::class, EmailTemplateRepositoryInterface::class => EmailTemplateRepository::class, - AccountStripePlatformRepositoryInterface::class => AccountStripePlatformRepository::class, - AccountVatSettingRepositoryInterface::class => AccountVatSettingRepository::class, + OrganizerStripePlatformRepositoryInterface::class => OrganizerStripePlatformRepository::class, + OrganizerVatSettingRepositoryInterface::class => OrganizerVatSettingRepository::class, + OrganizerConfigurationRepositoryInterface::class => OrganizerConfigurationRepository::class, TicketLookupTokenRepositoryInterface::class => TicketLookupTokenRepository::class, AccountMessagingTierRepositoryInterface::class => AccountMessagingTierRepository::class, WaitlistEntryRepositoryInterface::class => WaitlistEntryRepository::class, + EventOccurrenceRepositoryInterface::class => EventOccurrenceRepository::class, + EventOccurrenceStatisticRepositoryInterface::class => EventOccurrenceStatisticRepository::class, + EventOccurrenceDailyStatisticRepositoryInterface::class => EventOccurrenceDailyStatisticRepository::class, + ProductOccurrenceVisibilityRepositoryInterface::class => ProductOccurrenceVisibilityRepository::class, + ProductPriceOccurrenceOverrideRepositoryInterface::class => ProductPriceOccurrenceOverrideRepository::class, ]; public function register(): void diff --git a/backend/app/Repository/DTO/CheckInListProductStatDTO.php b/backend/app/Repository/DTO/CheckInListProductStatDTO.php new file mode 100644 index 0000000000..0d2a1fa67b --- /dev/null +++ b/backend/app/Repository/DTO/CheckInListProductStatDTO.php @@ -0,0 +1,17 @@ +name; + // Joining organizers + organizer_stripe_platforms here would multiply + // each event row by (organizers per account × stripe platforms per organizer), + // silently inflating SUM(sales_total_gross) and SUM(orders_created). + // Use EXISTS for the stripe_connected check instead. $query = DB::table('account_attributions as aa') ->select([ DB::raw("COALESCE(aa.{$groupColumn}, '(not set)') as attribution_value"), DB::raw('COUNT(DISTINCT aa.account_id) as total_accounts'), DB::raw('COUNT(DISTINCT e.id) as total_events'), DB::raw("COUNT(DISTINCT CASE WHEN e.status = '{$liveStatus}' THEN e.id END) as live_events"), - DB::raw('COUNT(DISTINCT CASE WHEN asp.stripe_setup_completed_at IS NOT NULL THEN aa.account_id END) as stripe_connected'), + DB::raw('COUNT(DISTINCT CASE WHEN EXISTS (SELECT 1 FROM organizers o2 JOIN organizer_stripe_platforms osp2 ON osp2.organizer_id = o2.id WHERE o2.account_id = aa.account_id AND o2.deleted_at IS NULL AND osp2.deleted_at IS NULL AND osp2.stripe_setup_completed_at IS NOT NULL) THEN aa.account_id END) as stripe_connected'), DB::raw('COUNT(DISTINCT CASE WHEN a.is_manually_verified = true THEN aa.account_id END) as verified_accounts'), DB::raw('COALESCE(SUM(es.sales_total_gross), 0) as total_revenue'), DB::raw('COALESCE(SUM(es.orders_created), 0) as total_orders'), ]) ->join('accounts as a', 'aa.account_id', '=', 'a.id') - ->leftJoin('account_stripe_platforms as asp', function ($join) { - $join->on('a.id', '=', 'asp.account_id') - ->whereNull('asp.deleted_at'); - }) ->leftJoin('events as e', function ($join) { $join->on('a.id', '=', 'e.account_id') ->whereNull('e.deleted_at'); diff --git a/backend/app/Repository/Eloquent/AccountRepository.php b/backend/app/Repository/Eloquent/AccountRepository.php index 54f136d31b..a7c5b01801 100644 --- a/backend/app/Repository/Eloquent/AccountRepository.php +++ b/backend/app/Repository/Eloquent/AccountRepository.php @@ -26,16 +26,16 @@ public function getDomainObject(): string public function findByEventId(int $eventId): AccountDomainObject { - $account = $this - ->model - ->select('accounts.*') - ->join('events', 'accounts.id', '=', 'events.account_id') - ->where('events.id', $eventId) - ->first(); - - $this->resetModel(); + return $this->runQuery(function () use ($eventId) { + $account = $this + ->model + ->select('accounts.*') + ->join('events', 'accounts.id', '=', 'events.account_id') + ->where('events.id', $eventId) + ->first(); - return $this->handleSingleResult($account, AccountDomainObject::class); + return $this->handleSingleResult($account, AccountDomainObject::class); + }); } public function getAllAccountsWithCounts(?string $search, int $perPage): LengthAwarePaginator @@ -69,13 +69,13 @@ public function getAccountWithDetails(int $accountId): Account return $this->model ->withCount(['events', 'users']) ->with([ - 'configuration', - 'account_vat_setting', 'messagingTier', + 'organizers.organizer_configuration', + 'organizers.organizer_vat_setting', 'users' => function ($query) { $query->select('users.id', 'users.first_name', 'users.last_name', 'users.email') ->withPivot('role'); - } + }, ]) ->findOrFail($accountId); } diff --git a/backend/app/Repository/Eloquent/AccountStripePlatformRepository.php b/backend/app/Repository/Eloquent/AccountStripePlatformRepository.php deleted file mode 100644 index d8f45cc0af..0000000000 --- a/backend/app/Repository/Eloquent/AccountStripePlatformRepository.php +++ /dev/null @@ -1,25 +0,0 @@ - - */ -class AccountStripePlatformRepository extends BaseRepository implements AccountStripePlatformRepositoryInterface -{ - protected function getModel(): string - { - return AccountStripePlatform::class; - } - - public function getDomainObject(): string - { - return AccountStripePlatformDomainObject::class; - } -} diff --git a/backend/app/Repository/Eloquent/AccountVatSettingRepository.php b/backend/app/Repository/Eloquent/AccountVatSettingRepository.php deleted file mode 100644 index 6e9a7393fe..0000000000 --- a/backend/app/Repository/Eloquent/AccountVatSettingRepository.php +++ /dev/null @@ -1,28 +0,0 @@ - - */ -class AccountVatSettingRepository extends BaseRepository implements AccountVatSettingRepositoryInterface -{ - protected function getModel(): string - { - return AccountVatSetting::class; - } - - public function getDomainObject(): string - { - return AccountVatSettingDomainObject::class; - } - - public function findByAccountId(int $accountId): ?AccountVatSettingDomainObject - { - return $this->findFirstWhere(['account_id' => $accountId]); - } -} diff --git a/backend/app/Repository/Eloquent/AttendeeRepository.php b/backend/app/Repository/Eloquent/AttendeeRepository.php index 8f2ce62ff0..76faee35ed 100644 --- a/backend/app/Repository/Eloquent/AttendeeRepository.php +++ b/backend/app/Repository/Eloquent/AttendeeRepository.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\AttendeeCheckInDomainObject; use HiEvents\DomainObjects\AttendeeDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\Generated\AttendeeDomainObjectAbstract; use HiEvents\DomainObjects\Status\AttendeeStatus; use HiEvents\DomainObjects\Status\OrderStatus; @@ -32,31 +33,37 @@ public function getDomainObject(): string return AttendeeDomainObject::class; } - public function findByEventIdForExport(int $eventId): Collection + public function findByEventIdForExport(int $eventId, ?int $eventOccurrenceId = null): Collection { - $this->applyConditions([ - 'attendees.event_id' => $eventId, - ]); - - $this->model->select('attendees.*'); - $this->model->join('orders', 'orders.id', '=', 'attendees.order_id'); - $this->model->whereIn('orders.status', [ - OrderStatus::AWAITING_OFFLINE_PAYMENT->name, - OrderStatus::COMPLETED->name, - OrderStatus::CANCELLED->name - ]); - - $model = $this->model->limit(10000)->get(); - $this->resetModel(); - - return $this->handleResults($model); - } + return $this->runQuery(function () use ($eventId, $eventOccurrenceId) { + $conditions = [ + 'attendees.event_id' => $eventId, + ]; + + if ($eventOccurrenceId !== null) { + $conditions['attendees.event_occurrence_id'] = $eventOccurrenceId; + } + + $this->applyConditions($conditions); + + $this->model->select('attendees.*'); + $this->model->join('orders', 'orders.id', '=', 'attendees.order_id'); + $this->model->whereIn('orders.status', [ + OrderStatus::AWAITING_OFFLINE_PAYMENT->name, + OrderStatus::COMPLETED->name, + OrderStatus::CANCELLED->name, + ]); + $model = $this->model->limit(10000)->get(); + + return $this->handleResults($model); + }); + } public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator { $where = [ - ['attendees.event_id', '=', $eventId] + ['attendees.event_id', '=', $eventId], ]; if ($params->query) { @@ -66,14 +73,14 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware DB::raw( sprintf( "(%s||' '||%s)", - 'attendees.' . AttendeeDomainObjectAbstract::FIRST_NAME, - 'attendees.' . AttendeeDomainObjectAbstract::LAST_NAME, + 'attendees.'.AttendeeDomainObjectAbstract::FIRST_NAME, + 'attendees.'.AttendeeDomainObjectAbstract::LAST_NAME, ) - ), 'ilike', '%' . $params->query . '%') - ->orWhere('attendees.' . AttendeeDomainObjectAbstract::LAST_NAME, 'ilike', '%' . $params->query . '%') - ->orWhere('attendees.' . AttendeeDomainObjectAbstract::FIRST_NAME, 'ilike', '%' . $params->query . '%') - ->orWhere('attendees.' . AttendeeDomainObjectAbstract::PUBLIC_ID, 'ilike', '%' . $params->query . '%') - ->orWhere('attendees.' . AttendeeDomainObjectAbstract::EMAIL, 'ilike', '%' . $params->query . '%'); + ), 'ilike', '%'.$params->query.'%') + ->orWhere('attendees.'.AttendeeDomainObjectAbstract::LAST_NAME, 'ilike', '%'.$params->query.'%') + ->orWhere('attendees.'.AttendeeDomainObjectAbstract::FIRST_NAME, 'ilike', '%'.$params->query.'%') + ->orWhere('attendees.'.AttendeeDomainObjectAbstract::PUBLIC_ID, 'ilike', '%'.$params->query.'%') + ->orWhere('attendees.'.AttendeeDomainObjectAbstract::EMAIL, 'ilike', '%'.$params->query.'%'); }; } @@ -93,7 +100,7 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware ->leftJoin('products', 'products.id', '=', 'attendees.product_id') ->orderBy('products.title', $sortDirection); } else { - $this->model = $this->model->orderBy('attendees.' . $sortBy, $sortDirection); + $this->model = $this->model->orderBy('attendees.'.$sortBy, $sortDirection); } return $this->paginateWhere( @@ -113,26 +120,53 @@ public function getAttendeesByCheckInShortId(string $shortId, QueryParamsDTO $pa DB::raw( sprintf( "(%s||' '||%s)", - 'attendees.' . AttendeeDomainObjectAbstract::FIRST_NAME, - 'attendees.' . AttendeeDomainObjectAbstract::LAST_NAME, + 'attendees.'.AttendeeDomainObjectAbstract::FIRST_NAME, + 'attendees.'.AttendeeDomainObjectAbstract::LAST_NAME, ) - ), 'ilike', '%' . $params->query . '%') - ->orWhere('attendees.' . AttendeeDomainObjectAbstract::LAST_NAME, 'ilike', '%' . $params->query . '%') - ->orWhere('attendees.' . AttendeeDomainObjectAbstract::FIRST_NAME, 'ilike', '%' . $params->query . '%') - ->orWhere('attendees.' . AttendeeDomainObjectAbstract::PUBLIC_ID, 'ilike', '%' . $params->query . '%') - ->orWhere('attendees.' . AttendeeDomainObjectAbstract::EMAIL, 'ilike', '%' . $params->query . '%'); + ), 'ilike', '%'.$params->query.'%') + ->orWhere('attendees.'.AttendeeDomainObjectAbstract::LAST_NAME, 'ilike', '%'.$params->query.'%') + ->orWhere('attendees.'.AttendeeDomainObjectAbstract::FIRST_NAME, 'ilike', '%'.$params->query.'%') + ->orWhere('attendees.'.AttendeeDomainObjectAbstract::PUBLIC_ID, 'ilike', '%'.$params->query.'%') + ->orWhere('attendees.'.AttendeeDomainObjectAbstract::EMAIL, 'ilike', '%'.$params->query.'%'); }; } + // "Empty attachments = all tickets": join the list via event_id and use + // EXISTS branches rather than an INNER JOIN on product_check_in_lists. $this->model = $this->model->select('attendees.*') ->join('orders', 'orders.id', '=', 'attendees.order_id') - ->join('product_check_in_lists', 'product_check_in_lists.product_id', '=', 'attendees.product_id') - ->join('check_in_lists', 'check_in_lists.id', '=', 'product_check_in_lists.check_in_list_id') - ->where('check_in_lists.short_id', $shortId) - ->whereIn('attendees.status',[AttendeeStatus::ACTIVE->name, AttendeeStatus::CANCELLED->name, AttendeeStatus::AWAITING_PAYMENT->name]) + ->join('check_in_lists', function ($join) use ($shortId) { + $join->on('check_in_lists.event_id', '=', 'attendees.event_id') + ->where('check_in_lists.short_id', '=', $shortId) + ->whereNull('check_in_lists.deleted_at'); + }) + ->where(function ($query) { + $query->whereExists(function ($sub) { + $sub->select(DB::raw(1)) + ->from('product_check_in_lists as pcil') + ->whereColumn('pcil.check_in_list_id', 'check_in_lists.id') + ->whereColumn('pcil.product_id', 'attendees.product_id') + ->whereNull('pcil.deleted_at'); + })->orWhereNotExists(function ($sub) { + $sub->select(DB::raw(1)) + ->from('product_check_in_lists as pcil') + ->whereColumn('pcil.check_in_list_id', 'check_in_lists.id') + ->whereNull('pcil.deleted_at'); + }); + }) + ->whereIn('attendees.status', [AttendeeStatus::ACTIVE->name, AttendeeStatus::CANCELLED->name, AttendeeStatus::AWAITING_PAYMENT->name]) ->whereIn('orders.status', [OrderStatus::COMPLETED->name, OrderStatus::AWAITING_OFFLINE_PAYMENT->name]); + $occurrenceFilter = $params->filter_fields?->firstWhere('field', 'event_occurrence_id'); + if ($occurrenceFilter) { + $this->model = $this->model->where( + 'attendees.event_occurrence_id', + $occurrenceFilter->value + ); + } + $this->loadRelation(new Relationship(AttendeeCheckInDomainObject::class, name: 'check_ins')); + $this->loadRelation(new Relationship(EventOccurrenceDomainObject::class, name: 'event_occurrence')); return $this->simplePaginateWhere( where: $where, diff --git a/backend/app/Repository/Eloquent/BaseRepository.php b/backend/app/Repository/Eloquent/BaseRepository.php index f00f8717c9..5c3a93a034 100644 --- a/backend/app/Repository/Eloquent/BaseRepository.php +++ b/backend/app/Repository/Eloquent/BaseRepository.php @@ -6,6 +6,7 @@ use BadMethodCallException; use Carbon\Carbon; +use Closure; use HiEvents\DomainObjects\Interfaces\DomainObjectInterface; use HiEvents\DomainObjects\Interfaces\IsSortable; use HiEvents\Http\DTO\QueryParamsDTO; @@ -18,6 +19,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Foundation\Application; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; @@ -26,6 +28,7 @@ /** * @template T of DomainObjectInterface + * * @implements RepositoryInterface */ abstract class BaseRepository implements RepositoryInterface @@ -50,20 +53,18 @@ public function __construct(Application $application, DatabaseManager $db) /** * Returns a FQCL of the model - * - * @return string */ abstract protected function getModel(): string; /** - * @param class-string $domainObjectClass + * @param class-string $domainObjectClass */ protected function validateSortColumn(?string $sortBy, string $domainObjectClass): string { $allowedColumns = array_keys($domainObjectClass::getAllowedSorts()->toArray()); $default = $domainObjectClass::getDefaultSort(); - if ($sortBy === null || !in_array($sortBy, $allowedColumns, true)) { + if ($sortBy === null || ! in_array($sortBy, $allowedColumns, true)) { return $default; } @@ -86,61 +87,63 @@ public function setMaxPerPage(int $maxPerPage): static public function all(array $columns = self::DEFAULT_COLUMNS): Collection { - $models = $this->model->all($columns); - $this->resetModel(); - - return $this->handleResults($models); + return $this->runQuery( + fn () => $this->handleResults($this->model->all($columns)) + ); } public function paginate( - ?int $limit = null, + ?int $limit = null, array $columns = self::DEFAULT_COLUMNS - ): LengthAwarePaginator - { - $results = $this->model->paginate($this->getPaginationPerPage($limit), $columns); - $this->resetModel(); - - return $this->handleResults($results); + ): LengthAwarePaginator { + return $this->runQuery( + fn () => $this->handleResults( + $this->model->paginate($this->getPaginationPerPage($limit), $columns) + ) + ); } public function paginateWhere( array $where, - ?int $limit = null, + ?int $limit = null, array $columns = self::DEFAULT_COLUMNS, - ?int $page = null, - ): LengthAwarePaginator - { - $this->applyConditions($where); - $results = $this->model->paginate( - perPage: $this->getPaginationPerPage($limit), - columns: $columns, - page: $page, - ); - $this->resetModel(); + ?int $page = null, + ): LengthAwarePaginator { + return $this->runQuery(function () use ($where, $limit, $columns, $page) { + $this->applyConditions($where); - return $this->handleResults($results); + return $this->handleResults($this->model->paginate( + perPage: $this->getPaginationPerPage($limit), + columns: $columns, + page: $page, + )); + }); } public function simplePaginateWhere( array $where, - ?int $limit = null, + ?int $limit = null, array $columns = self::DEFAULT_COLUMNS, - ): Paginator - { - $this->applyConditions($where); - $results = $this->model->simplePaginate($this->getPaginationPerPage($limit), $columns); - $this->resetModel(); + ): Paginator { + return $this->runQuery(function () use ($where, $limit, $columns) { + $this->applyConditions($where); - return $this->handleResults($results); + return $this->handleResults( + $this->model->simplePaginate($this->getPaginationPerPage($limit), $columns) + ); + }); } public function paginateEloquentRelation( Relation $relation, - ?int $limit = null, - array $columns = self::DEFAULT_COLUMNS - ): LengthAwarePaginator - { - return $this->handleResults($relation->paginate($this->getPaginationPerPage($limit), $columns)); + ?int $limit = null, + array $columns = self::DEFAULT_COLUMNS + ): LengthAwarePaginator { + return $this->runQuery( + fn () => $this->handleResults( + $relation->paginate($this->getPaginationPerPage($limit), $columns) + ) + ); } /** @@ -148,101 +151,99 @@ public function paginateEloquentRelation( */ public function findById(int $id, array $columns = self::DEFAULT_COLUMNS): DomainObjectInterface { - $model = $this->model->findOrFail($id, $columns); - $this->resetModel(); - - return $this->handleSingleResult($model); + return $this->runQuery( + fn () => $this->handleSingleResult($this->model->findOrFail($id, $columns)) + ); } public function findFirstByField( - string $field, + string $field, ?string $value = null, - array $columns = ['*'] - ): ?DomainObjectInterface - { - $model = $this->model->where($field, '=', $value)->first($columns); - $this->resetModel(); - - return $this->handleSingleResult($model); + array $columns = ['*'] + ): ?DomainObjectInterface { + return $this->runQuery( + fn () => $this->handleSingleResult( + $this->model->where($field, '=', $value)->first($columns) + ) + ); } public function findFirst(int $id, array $columns = self::DEFAULT_COLUMNS): ?DomainObjectInterface { - $model = $this->model->findOrFail($id, $columns); - $this->resetModel(); - - return $this->handleSingleResult($model); + return $this->runQuery( + fn () => $this->handleSingleResult($this->model->findOrFail($id, $columns)) + ); } public function findWhere( array $where, array $columns = self::DEFAULT_COLUMNS, array $orderAndDirections = [], - ): Collection - { - $this->applyConditions($where); + ?int $limit = null, + ): Collection { + return $this->runQuery(function () use ($where, $columns, $orderAndDirections, $limit) { + $this->applyConditions($where); - if ($orderAndDirections) { foreach ($orderAndDirections as $orderAndDirection) { $this->model = $this->model->orderBy( $orderAndDirection->getOrder(), $orderAndDirection->getDirection() ); } - } - $model = $this->model->get($columns); - - $this->resetModel(); + if ($limit !== null) { + $this->model = $this->model->limit($limit); + } - return $this->handleResults($model); + return $this->handleResults($this->model->get($columns)); + }); } public function findFirstWhere(array $where, array $columns = self::DEFAULT_COLUMNS): ?DomainObjectInterface { - $this->applyConditions($where); - $model = $this->model->first($columns); - $this->resetModel(); + return $this->runQuery(function () use ($where, $columns) { + $this->applyConditions($where); - return $this->handleSingleResult($model); + return $this->handleSingleResult($this->model->first($columns)); + }); } public function findWhereIn(string $field, array $values, array $additionalWhere = [], array $columns = self::DEFAULT_COLUMNS): Collection { - if ($additionalWhere) { - $this->applyConditions($additionalWhere); - } - - $model = $this->model->whereIn($field, $values)->get($columns); - $this->resetModel(); + return $this->runQuery(function () use ($field, $values, $additionalWhere, $columns) { + if ($additionalWhere) { + $this->applyConditions($additionalWhere); + } - return $this->handleResults($model); + return $this->handleResults($this->model->whereIn($field, $values)->get($columns)); + }); } public function create(array $attributes): DomainObjectInterface { - $model = $this->model->newInstance(collect($attributes)->toArray()); - $model->save(); - $this->resetModel(); + return $this->runQuery(function () use ($attributes) { + $model = $this->model->newInstance(collect($attributes)->toArray()); + $model->save(); - return $this->handleSingleResult($model); + return $this->handleSingleResult($model); + }); } public function insert(array $inserts): bool { - // When doing a bulk insert Eloquent doesn't autofill the updated/created dates, - // so we need to do it manually - foreach ($inserts as $index => $insert) { - if (!isset($insert['created_at'], $insert['updated_at'])) { - $now = Carbon::now(); - $inserts[$index]['created_at'] = $now; - $inserts[$index]['updated_at'] = $now; + return $this->runQuery(function () use ($inserts) { + // When doing a bulk insert Eloquent doesn't autofill the updated/created dates, + // so we need to do it manually + foreach ($inserts as $index => $insert) { + if (! isset($insert['created_at'], $insert['updated_at'])) { + $now = Carbon::now(); + $inserts[$index]['created_at'] = $now; + $inserts[$index]['updated_at'] = $now; + } } - } - $insert = $this->model->insert($inserts); - $this->resetModel(); - return $insert; + return $this->model->insert($inserts); + }); } public function updateFromDomainObject(int $id, DomainObjectInterface $domainObject): DomainObjectInterface @@ -252,93 +253,103 @@ public function updateFromDomainObject(int $id, DomainObjectInterface $domainObj public function updateFromArray(int $id, array $attributes): DomainObjectInterface { - $model = $this->model->findOrFail($id); - $model->fill($attributes); - $model->save(); - $this->resetModel(); + return $this->runQuery(function () use ($id, $attributes) { + $model = $this->model->findOrFail($id); + $model->fill($attributes); + $model->save(); - return $this->handleSingleResult($model); + return $this->handleSingleResult($model); + }); } public function updateWhere(array $attributes, array $where): int { - $this->applyConditions($where); - $count = $this->model->update($attributes); - $this->resetModel(); + return $this->runQuery(function () use ($attributes, $where) { + $this->applyConditions($where); - return $count; + return $this->model->update($attributes); + }); } public function updateByIdWhere(int $id, array $attributes, array $where): DomainObjectInterface { - $model = $this->model->where($where)->findOrFail($id); - $model->update($attributes); - $this->resetModel(); + return $this->runQuery(function () use ($id, $attributes, $where) { + $model = $this->model->where($where)->findOrFail($id); + $model->update($attributes); - return $this->handleSingleResult($model); + return $this->handleSingleResult($model); + }); } public function deleteById(int $id): bool { - return $this->model->findOrFail($id)->delete(); + return $this->runQuery( + fn () => (bool) $this->model->findOrFail($id)->delete() + ); } public function incrementEach(array $columns, array $additionalUpdates = [], ?array $where = null): int { - if ($where) { - $this->applyConditions($where); - } - - $count = $this->model->incrementEach($columns, $additionalUpdates); - $this->resetModel(); + return $this->runQuery(function () use ($columns, $additionalUpdates, $where) { + if ($where) { + $this->applyConditions($where); + } - return $count; + // Eloquent\Builder's __call swallows incrementEach's int return value + // and hands back the Builder, so we route through the underlying + // QueryBuilder to get the affected-row count. + return $this->resolveBaseQuery()->incrementEach($columns, $additionalUpdates); + }); } public function decrementEach(array $where, array $columns, array $extra = []): int { - $this->applyConditions($where); - $count = $this->model->decrementEach($columns, $extra); - $this->resetModel(); + return $this->runQuery(function () use ($where, $columns, $extra) { + $this->applyConditions($where); - return $count; + return $this->resolveBaseQuery()->decrementEach($columns, $extra); + }); } public function increment(int|float $id, string $column, int|float $amount = 1): int { - return $this->model->findOrFail($id)->increment($column, $amount); + return $this->runQuery( + fn () => $this->model->findOrFail($id)->increment($column, $amount) + ); } public function incrementWhere(array $where, string $column, int|float $amount = 1): int { - $this->applyConditions($where); - $count = $this->model->increment($column, $amount); - $this->resetModel(); + return $this->runQuery(function () use ($where, $column, $amount) { + $this->applyConditions($where); - return $count; + return $this->model->increment($column, $amount); + }); } public function decrement(int|float $id, string $column, int|float $amount = 1): int { - return $this->model->findOrFail($id)?->decrement($column, $amount); + return $this->runQuery( + fn () => $this->model->findOrFail($id)->decrement($column, $amount) + ); } public function deleteWhere(array $conditions): int { - $this->applyConditions($conditions); - $deleted = $this->model->delete(); - $this->resetModel(); + return $this->runQuery(function () use ($conditions) { + $this->applyConditions($conditions); - return $deleted; + return $this->model->delete(); + }); } public function countWhere(array $conditions): int { - $this->applyConditions($conditions); - $count = $this->model->count(); - $this->resetModel(); + return $this->runQuery(function () use ($conditions) { + $this->applyConditions($conditions); - return $count; + return $this->model->count(); + }); } public function loadRelation(string|Relationship $relationship): static @@ -363,7 +374,7 @@ public function includeDeleted(): static protected function applyConditions(array $where): void { foreach ($where as $field => $value) { - if (is_callable($value) && !is_string($value)) { + if (is_callable($value) && ! is_string($value)) { $this->model = $this->model->where($value); } elseif (is_array($value)) { [$field, $condition, $val] = $value; @@ -406,6 +417,48 @@ protected function initModel(?string $model = null): Model return $this->app->make($model ?: $this->getModel()); } + /** + * Execute a query callback and guarantee per-call state is reset afterwards, + * even if the callback throws. This is the single point at which the in-flight + * builder ($this->model) and the eager-load list ($this->eagerLoads) are cleared. + * + * The callback runs BEFORE reset, so hydration helpers that read $this->eagerLoads + * (e.g. handleEagerLoads()) still see the correct state. + * + * @template TReturn + * + * @param Closure(): TReturn $callback + * @return TReturn + */ + protected function runQuery(Closure $callback): mixed + { + try { + return $callback(); + } finally { + $this->resetState(); + } + } + + protected function resetState(): void + { + $model = $this->getModel(); + $this->model = new $model; + $this->eagerLoads = []; + } + + /** + * Resolve $this->model (which may be a fresh Model or an Eloquent Builder + * after applyConditions()) to the underlying query builder. Required for + * methods Eloquent\Builder::__call swallows the return value of, e.g. + * incrementEach() / decrementEach(). + */ + private function resolveBaseQuery(): QueryBuilder + { + return $this->model instanceof Builder + ? $this->model->getQuery() + : $this->model->newQuery()->getQuery(); + } + protected function handleResults($results, ?string $domainObjectOverride = null) { $domainObjects = []; @@ -428,10 +481,9 @@ protected function handleResults($results, ?string $domainObjectOverride = null) protected function handleSingleResult( ?BaseModel $model, - ?string $domainObjectOverride = null - ): ?DomainObjectInterface - { - if (!$model) { + ?string $domainObjectOverride = null + ): ?DomainObjectInterface { + if (! $model) { return null; } @@ -442,11 +494,10 @@ protected function applyFilterFields( QueryParamsDTO $params, array $allowedFilterFields = [], ?string $prefix = null, - ): void - { + ): void { if ($params->filter_fields && $params->filter_fields->isNotEmpty()) { $params->filter_fields->each(function ($filterField) use ($prefix, $allowedFilterFields) { - if (!in_array($filterField->field, $allowedFilterFields, true)) { + if (! in_array($filterField->field, $allowedFilterFields, true)) { return; } @@ -467,7 +518,7 @@ protected function applyFilterFields( sprintf('Operator %s is not supported', $filterField->operator) ); - $field = $prefix ? $prefix . '.' . $filterField->field : $filterField->field; + $field = $prefix ? $prefix.'.'.$filterField->field : $filterField->field; // Special handling for IN operator if ($operator === 'IN') { @@ -491,10 +542,13 @@ protected function applyFilterFields( } } + /** + * @deprecated Use resetState() instead. Kept for backwards compatibility with + * subclass repositories that build custom queries on $this->model. + */ protected function resetModel(): void { - $model = $this->getModel(); - $this->model = new $model(); + $this->resetState(); } private function getPaginationPerPage(?int $perPage): int @@ -503,30 +557,26 @@ private function getPaginationPerPage(?int $perPage): int $perPage = self::DEFAULT_PAGINATE_LIMIT; } - return (int)min($perPage, $this->maxPerPage); + return (int) min($perPage, $this->maxPerPage); } /** - * @param Model $model - * @param string|null $domainObjectOverride A FQCN of a DO - * @param array|null $relationships - * @return DomainObjectInterface + * @param string|null $domainObjectOverride A FQCN of a DO * * @todo use hydrate method from AbstractDomainObject */ private function hydrateDomainObjectFromModel( - Model $model, + Model $model, ?string $domainObjectOverride = null, - ?array $relationships = null, - ): DomainObjectInterface - { + ?array $relationships = null, + ): DomainObjectInterface { /** @var DomainObjectInterface $object */ $object = $domainObjectOverride ?: $this->getDomainObject(); - $object = new $object(); + $object = new $object; foreach ($model->attributesToArray() as $attribute => $value) { - $method = 'set' . ucfirst(Str::camel($attribute)); - if (is_callable(array($object, $method))) { + $method = 'set'.Str::studly($attribute); + if (is_callable([$object, $method])) { try { $object->$method($value); } catch (TypeError $e) { @@ -538,7 +588,7 @@ private function hydrateDomainObjectFromModel( var_export($value, true), $e->getMessage() ), - (int)$e->getCode(), + (int) $e->getCode(), $e ); } @@ -554,24 +604,20 @@ private function hydrateDomainObjectFromModel( /** * This method will handle nested eager loading of relationships * - * @param Model $model - * @param DomainObjectInterface $object - * @param Relationship[]|null $relationships - * - * @return void + * @param Relationship[]|null $relationships */ private function handleEagerLoads(Model $model, DomainObjectInterface $object, ?array $relationships): void { $eagerLoads = $relationships ?: $this->eagerLoads; foreach ($eagerLoads as $eagerLoad) { - if (!$model->relationLoaded($eagerLoad->getName())) { + if (! $model->relationLoaded($eagerLoad->getName())) { continue; } $relatedModels = $model->getRelation($eagerLoad->getName()); - $setterMethod = 'set' . Str::studly($eagerLoad->getName()); + $setterMethod = 'set'.Str::studly($eagerLoad->getName()); - if (!is_callable([$object, $setterMethod])) { + if (! is_callable([$object, $setterMethod])) { throw new BadMethodCallException( sprintf( 'Method %s is not callable on %s. Does it exist?', @@ -590,7 +636,7 @@ private function handleEagerLoads(Model $model, DomainObjectInterface $object, ? ); }); $object->$setterMethod($relatedDomainObjects); - } else if ($relatedModels instanceof BaseModel) { + } elseif ($relatedModels instanceof BaseModel) { $relatedDomainObject = $this->hydrateDomainObjectFromModel( $relatedModels, $eagerLoad->getDomainObject(), diff --git a/backend/app/Repository/Eloquent/CheckInListRepository.php b/backend/app/Repository/Eloquent/CheckInListRepository.php index 46b37358da..b5e81d496c 100644 --- a/backend/app/Repository/Eloquent/CheckInListRepository.php +++ b/backend/app/Repository/Eloquent/CheckInListRepository.php @@ -8,6 +8,8 @@ use HiEvents\Http\DTO\QueryParamsDTO; use HiEvents\Models\CheckInList; use HiEvents\Repository\DTO\CheckedInAttendeesCountDTO; +use HiEvents\Repository\DTO\CheckInListProductStatDTO; +use HiEvents\Repository\DTO\CheckInListRecentCheckInDTO; use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface; use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; @@ -28,26 +30,48 @@ public function getDomainObject(): string return CheckInListDomainObject::class; } - public function getCheckedInAttendeeCountById(int $checkInListId): CheckedInAttendeesCountDTO - { + public function getCheckedInAttendeeCountById( + int $checkInListId, + ?int $eventOccurrenceIdOverride = null, + ): CheckedInAttendeesCountDTO { + $clause = $this->buildOccurrenceFilterClauses($eventOccurrenceIdOverride); + + // "Empty attachments = all tickets": valid_attendees joins the list via + // event_id and uses EXISTS/NOT EXISTS to express "attached, or list has + // no attachments". $sql = <<checkInClause} GROUP BY attendee_id, check_in_list_id ), valid_attendees AS ( - SELECT a.id, pcil.check_in_list_id + SELECT a.id, cil.id AS check_in_list_id FROM attendees a - JOIN product_check_in_lists pcil ON a.product_id = pcil.product_id JOIN orders o ON a.order_id = o.id - JOIN check_in_lists cil ON pcil.check_in_list_id = cil.id + JOIN check_in_lists cil ON cil.event_id = a.event_id + AND cil.id = :check_in_list_id + AND cil.deleted_at IS NULL JOIN event_settings es ON cil.event_id = es.event_id WHERE a.deleted_at IS NULL - AND pcil.deleted_at IS NULL - AND pcil.check_in_list_id = :check_in_list_id + {$clause->attendeeClause} + AND ( + EXISTS ( + SELECT 1 FROM product_check_in_lists pcil + WHERE pcil.check_in_list_id = cil.id + AND pcil.product_id = a.product_id + AND pcil.deleted_at IS NULL + ) + OR NOT EXISTS ( + SELECT 1 FROM product_check_in_lists pcil + WHERE pcil.check_in_list_id = cil.id + AND pcil.deleted_at IS NULL + ) + ) AND ( (es.allow_orders_awaiting_offline_payment_to_check_in = true AND a.status in ('ACTIVE', 'AWAITING_PAYMENT') AND o.status IN ('COMPLETED', 'AWAITING_OFFLINE_PAYMENT')) OR @@ -66,7 +90,10 @@ public function getCheckedInAttendeeCountById(int $checkInListId): CheckedInAtte GROUP BY cil.id; SQL; - $query = $this->db->selectOne($sql, ['check_in_list_id' => $checkInListId]); + $query = $this->db->selectOne( + $sql, + array_merge(['check_in_list_id' => $checkInListId], $clause->bindings), + ); return new CheckedInAttendeesCountDTO( checkInListId: $checkInListId, @@ -75,28 +102,72 @@ public function getCheckedInAttendeeCountById(int $checkInListId): CheckedInAtte ); } + /** + * Build the WHERE fragments and bindings that restrict stats queries to a + * specific event occurrence. + * + * - Override set: count only attendees/check-ins with matching event_occurrence_id. + * - Override null: auto-scope to the check-in list's own event_occurrence_id if + * set; otherwise count across all occurrences (unscoped "All occurrences" list). + */ + private function buildOccurrenceFilterClauses(?int $override): object + { + if ($override !== null) { + return (object) [ + 'attendeeClause' => 'AND a.event_occurrence_id = :occurrence_id', + 'checkInClause' => 'AND aci.event_occurrence_id = :occurrence_id', + 'bindings' => ['occurrence_id' => $override], + ]; + } + + // Auto-scope to the list's own occurrence when set. A null on the list + // means "All occurrences" — no row-level filter. + return (object) [ + 'attendeeClause' => 'AND (cil.event_occurrence_id IS NULL OR a.event_occurrence_id = cil.event_occurrence_id)', + 'checkInClause' => 'AND (cil.event_occurrence_id IS NULL OR aci.event_occurrence_id = cil.event_occurrence_id)', + 'bindings' => [], + ]; + } + public function getCheckedInAttendeeCountByIds(array $checkInListIds): Collection { $placeholders = implode(',', array_fill(0, count($checkInListIds), '?')); + // Bulk version: auto-scopes each list via cil.event_occurrence_id (no + // single override). Same "empty attachments = all tickets" rule applies. $sql = <<buildOccurrenceFilterClauses($eventOccurrenceIdOverride); + + // For the product breakdown, "empty attachments" returns a row for every + // product on the event. + $sql = <<checkInClause} + GROUP BY aci.attendee_id, aci.check_in_list_id + ), + valid_attendees AS ( + SELECT a.id, a.product_id, cil.id AS check_in_list_id + FROM attendees a + JOIN orders o ON a.order_id = o.id + JOIN check_in_lists cil ON cil.event_id = a.event_id + AND cil.id = :check_in_list_id + AND cil.deleted_at IS NULL + JOIN event_settings es ON cil.event_id = es.event_id + WHERE a.deleted_at IS NULL + {$clause->attendeeClause} + AND ( + EXISTS ( + SELECT 1 FROM product_check_in_lists pcil + WHERE pcil.check_in_list_id = cil.id + AND pcil.product_id = a.product_id + AND pcil.deleted_at IS NULL + ) + OR NOT EXISTS ( + SELECT 1 FROM product_check_in_lists pcil + WHERE pcil.check_in_list_id = cil.id + AND pcil.deleted_at IS NULL + ) + ) + AND ( + (es.allow_orders_awaiting_offline_payment_to_check_in = true AND a.status IN ('ACTIVE', 'AWAITING_PAYMENT') AND o.status IN ('COMPLETED', 'AWAITING_OFFLINE_PAYMENT')) + OR + (es.allow_orders_awaiting_offline_payment_to_check_in = false AND a.status = 'ACTIVE' AND o.status = 'COMPLETED') + ) + ) + SELECT + p.id AS product_id, + p.title AS product_title, + COUNT(va.id) AS total_attendees, + COUNT(DISTINCT vci.attendee_id) AS checked_in_attendees + FROM products p + JOIN check_in_lists cil ON cil.id = :check_in_list_id + LEFT JOIN valid_attendees va ON va.product_id = p.id + LEFT JOIN valid_check_ins vci ON vci.attendee_id = va.id + WHERE p.deleted_at IS NULL + AND ( + EXISTS ( + SELECT 1 FROM product_check_in_lists pcil + WHERE pcil.check_in_list_id = cil.id + AND pcil.product_id = p.id + AND pcil.deleted_at IS NULL + ) + OR ( + p.event_id = cil.event_id + AND NOT EXISTS ( + SELECT 1 FROM product_check_in_lists pcil + WHERE pcil.check_in_list_id = cil.id + AND pcil.deleted_at IS NULL + ) + ) + ) + GROUP BY p.id, p.title + ORDER BY p.title; + SQL; + + $rows = $this->db->select( + $sql, + array_merge(['check_in_list_id' => $checkInListId], $clause->bindings), + ); + + return collect($rows)->map( + static fn($row) => new CheckInListProductStatDTO( + productId: (int)$row->product_id, + productTitle: $row->product_title, + totalAttendees: (int)$row->total_attendees, + checkedInAttendees: (int)$row->checked_in_attendees, + ) + ); + } + + public function getRecentCheckInsById( + int $checkInListId, + int $limit, + ?int $eventOccurrenceIdOverride = null, + ): Collection { + $clause = $this->buildOccurrenceFilterClauses($eventOccurrenceIdOverride); + + $sql = <<checkInClause} + ORDER BY aci.created_at DESC + LIMIT :row_limit; + SQL; + + $rows = $this->db->select($sql, array_merge([ + 'check_in_list_id' => $checkInListId, + 'row_limit' => $limit, + ], $clause->bindings)); + + return collect($rows)->map( + static fn($row) => new CheckInListRecentCheckInDTO( + attendeePublicId: $row->attendee_public_id, + firstName: $row->first_name ?? '', + lastName: $row->last_name ?? '', + productTitle: $row->product_title, + checkedInAt: (string)$row->checked_in_at, + ) + ); + } + public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator { $where = [ diff --git a/backend/app/Repository/Eloquent/EventOccurrenceDailyStatisticRepository.php b/backend/app/Repository/Eloquent/EventOccurrenceDailyStatisticRepository.php new file mode 100644 index 0000000000..b0dea88966 --- /dev/null +++ b/backend/app/Repository/Eloquent/EventOccurrenceDailyStatisticRepository.php @@ -0,0 +1,23 @@ + + */ +class EventOccurrenceDailyStatisticRepository extends BaseRepository implements EventOccurrenceDailyStatisticRepositoryInterface +{ + protected function getModel(): string + { + return EventOccurrenceDailyStatistic::class; + } + + public function getDomainObject(): string + { + return EventOccurrenceDailyStatisticDomainObject::class; + } +} diff --git a/backend/app/Repository/Eloquent/EventOccurrenceRepository.php b/backend/app/Repository/Eloquent/EventOccurrenceRepository.php new file mode 100644 index 0000000000..dbaad29b98 --- /dev/null +++ b/backend/app/Repository/Eloquent/EventOccurrenceRepository.php @@ -0,0 +1,67 @@ +where('id', $id) + ->lockForUpdate() + ->first(); + + if ($model === null) { + return null; + } + + return $this->handleSingleResult($model); + } + + public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator + { + $this->model = $this->model->newQuery()->orderBy( + column: $this->validateSortColumn($params->sort_by, EventOccurrenceDomainObject::class), + direction: $this->validateSortDirection($params->sort_direction, EventOccurrenceDomainObject::class), + ); + + if (!empty($params->filter_fields)) { + $this->applyFilterFields($params, EventOccurrenceDomainObject::getAllowedFilterFields()); + + $timePeriod = $params->filter_fields->firstWhere('field', 'time_period'); + if ($timePeriod) { + $now = now()->toDateTimeString(); + if ($timePeriod->value === 'upcoming') { + $this->model = $this->model->where('start_date', '>=', $now); + } elseif ($timePeriod->value === 'past') { + $this->model = $this->model->where('start_date', '<', $now); + } + } + } + + return $this->paginateWhere( + where: [ + EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId, + ], + limit: $params->per_page, + page: $params->page, + ); + } +} diff --git a/backend/app/Repository/Eloquent/EventOccurrenceStatisticRepository.php b/backend/app/Repository/Eloquent/EventOccurrenceStatisticRepository.php new file mode 100644 index 0000000000..065bde84f6 --- /dev/null +++ b/backend/app/Repository/Eloquent/EventOccurrenceStatisticRepository.php @@ -0,0 +1,23 @@ + + */ +class EventOccurrenceStatisticRepository extends BaseRepository implements EventOccurrenceStatisticRepositoryInterface +{ + protected function getModel(): string + { + return EventOccurrenceStatistic::class; + } + + public function getDomainObject(): string + { + return EventOccurrenceStatisticDomainObject::class; + } +} diff --git a/backend/app/Repository/Eloquent/EventRepository.php b/backend/app/Repository/Eloquent/EventRepository.php index 51773cc94b..9dbbaacbc3 100644 --- a/backend/app/Repository/Eloquent/EventRepository.php +++ b/backend/app/Repository/Eloquent/EventRepository.php @@ -17,6 +17,7 @@ use HiEvents\Repository\Interfaces\EventRepositoryInterface; use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Facades\DB; /** * @extends BaseRepository @@ -68,9 +69,15 @@ public function findEvents(array $where, QueryParamsDTO $params): LengthAwarePag $where[] = static function (Builder $builder) { $builder ->where(EventDomainObjectAbstract::STATUS, '!=', EventStatus::ARCHIVED->getName()) - ->where(function ($query) { - $query->whereNull(EventDomainObjectAbstract::END_DATE) - ->orWhere(EventDomainObjectAbstract::END_DATE, '>=', now()); + ->whereExists(function ($query) { + $query->select(DB::raw(1)) + ->from('event_occurrences') + ->whereColumn('event_occurrences.event_id', 'events.id') + ->whereNull('event_occurrences.deleted_at') + ->where(function ($q) { + $q->whereNull('event_occurrences.end_date') + ->orWhere('event_occurrences.end_date', '>=', now()); + }); }); }; @@ -100,19 +107,26 @@ public function getUpcomingEventsForAdmin(int $perPage): LengthAwarePaginator return $this->handleResults($this->model ->select('events.*') ->with(['account', 'organizer']) - ->where(EventDomainObjectAbstract::START_DATE, '>=', $now) - ->where(EventDomainObjectAbstract::START_DATE, '<=', $next24Hours) + ->whereExists(function ($query) use ($now, $next24Hours) { + $query->select(DB::raw(1)) + ->from('event_occurrences') + ->whereColumn('event_occurrences.event_id', 'events.id') + ->whereNull('event_occurrences.deleted_at') + ->where('event_occurrences.start_date', '>=', $now) + ->where('event_occurrences.start_date', '<=', $next24Hours) + ->where('event_occurrences.status', 'ACTIVE'); + }) ->whereIn(EventDomainObjectAbstract::STATUS, [ EventStatus::LIVE->name, ]) - ->orderBy(EventDomainObjectAbstract::START_DATE, 'asc') + ->orderBy(EventDomainObjectAbstract::CREATED_AT, 'desc') ->paginate($perPage)); } public function getAllEventsForAdmin( ?string $search = null, int $perPage = 20, - ?string $sortBy = 'start_date', + ?string $sortBy = 'created_at', ?string $sortDirection = 'desc' ): LengthAwarePaginator { $this->model = $this->model @@ -128,8 +142,8 @@ public function getAllEventsForAdmin( }); } - $allowedSortColumns = ['start_date', 'end_date', 'title', 'created_at']; - $sortColumn = in_array($sortBy, $allowedSortColumns, true) ? $sortBy : 'start_date'; + $allowedSortColumns = ['title', 'created_at', 'updated_at']; + $sortColumn = in_array($sortBy, $allowedSortColumns, true) ? $sortBy : 'created_at'; $sortDir = in_array(strtolower($sortDirection), ['asc', 'desc']) ? $sortDirection : 'desc'; $this->model = $this->model->orderBy($sortColumn, $sortDir); @@ -148,7 +162,6 @@ public function getSitemapEvents(int $page, int $perPage): LengthAwarePaginator 'events.' . EventDomainObjectAbstract::ID, 'events.' . EventDomainObjectAbstract::TITLE, 'events.' . EventDomainObjectAbstract::UPDATED_AT, - 'events.' . EventDomainObjectAbstract::START_DATE, ]) ->join('event_settings', 'events.id', '=', 'event_settings.event_id') ->where('events.' . EventDomainObjectAbstract::STATUS, EventStatus::LIVE->name) @@ -158,6 +171,16 @@ public function getSitemapEvents(int $page, int $perPage): LengthAwarePaginator ->paginate($perPage, ['*'], 'page', $page)); } + public function findByIdLocked(int $id): EventDomainObject + { + $model = Event::query() + ->where('id', $id) + ->lockForUpdate() + ->firstOrFail(); + + return $this->handleSingleResult($model); + } + public function getSitemapEventCount(): int { return $this->model diff --git a/backend/app/Repository/Eloquent/OrderItemRepository.php b/backend/app/Repository/Eloquent/OrderItemRepository.php index 72384aa8b3..e188432c30 100644 --- a/backend/app/Repository/Eloquent/OrderItemRepository.php +++ b/backend/app/Repository/Eloquent/OrderItemRepository.php @@ -3,6 +3,7 @@ namespace HiEvents\Repository\Eloquent; use HiEvents\DomainObjects\OrderItemDomainObject; +use HiEvents\DomainObjects\Status\OrderStatus; use HiEvents\Models\OrderItem; use HiEvents\Repository\Interfaces\OrderItemRepositoryInterface; @@ -20,4 +21,15 @@ public function getDomainObject(): string { return OrderItemDomainObject::class; } + + public function getReservedQuantityForOccurrence(int $occurrenceId): int + { + return (int) OrderItem::query() + ->join('orders', 'orders.id', '=', 'order_items.order_id') + ->where('order_items.event_occurrence_id', $occurrenceId) + ->where('orders.status', OrderStatus::RESERVED->name) + ->where('orders.reserved_until', '>', now()) + ->whereNull('orders.deleted_at') + ->sum('order_items.quantity'); + } } diff --git a/backend/app/Repository/Eloquent/OrderRepository.php b/backend/app/Repository/Eloquent/OrderRepository.php index f33c2629ee..e25f0edb89 100644 --- a/backend/app/Repository/Eloquent/OrderRepository.php +++ b/backend/app/Repository/Eloquent/OrderRepository.php @@ -4,7 +4,9 @@ namespace HiEvents\Repository\Eloquent; +use HiEvents\DomainObjects\AccountDomainObject; use HiEvents\DomainObjects\AttendeeDomainObject; +use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; @@ -19,8 +21,6 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; -use HiEvents\DomainObjects\EventDomainObject; -use HiEvents\DomainObjects\AccountDomainObject; /** * @extends BaseRepository @@ -45,15 +45,22 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware OrderDomainObjectAbstract::FIRST_NAME, OrderDomainObjectAbstract::LAST_NAME ) - ), 'ilike', '%' . $params->query . '%') - ->orWhere(OrderDomainObjectAbstract::LAST_NAME, 'ilike', '%' . $params->query . '%') - ->orWhere(OrderDomainObjectAbstract::PUBLIC_ID, 'ilike', '%' . $params->query . '%') - ->orWhere(OrderDomainObjectAbstract::EMAIL, 'ilike', '%' . $params->query . '%'); + ), 'ilike', '%'.$params->query.'%') + ->orWhere(OrderDomainObjectAbstract::LAST_NAME, 'ilike', '%'.$params->query.'%') + ->orWhere(OrderDomainObjectAbstract::PUBLIC_ID, 'ilike', '%'.$params->query.'%') + ->orWhere(OrderDomainObjectAbstract::EMAIL, 'ilike', '%'.$params->query.'%'); }; } - if (!empty($params->filter_fields)) { + if (! empty($params->filter_fields)) { $this->applyFilterFields($params, OrderDomainObject::getAllowedFilterFields()); + + $occurrenceFilter = $params->filter_fields->firstWhere('field', 'event_occurrence_id'); + if ($occurrenceFilter) { + $this->model = $this->model->whereHas('order_items', function (Builder $query) use ($occurrenceFilter) { + $query->where('order_items.event_occurrence_id', $occurrenceFilter->value); + }); + } } $this->model = $this->model->orderBy( @@ -85,14 +92,14 @@ public function findByOrganizerId(int $organizerId, int $accountId, QueryParamsD OrderDomainObjectAbstract::FIRST_NAME, OrderDomainObjectAbstract::LAST_NAME ) - ), 'ilike', '%' . $params->query . '%') - ->orWhere(OrderDomainObjectAbstract::LAST_NAME, 'ilike', '%' . $params->query . '%') - ->orWhere(OrderDomainObjectAbstract::PUBLIC_ID, 'ilike', '%' . $params->query . '%') - ->orWhere(OrderDomainObjectAbstract::EMAIL, 'ilike', '%' . $params->query . '%'); + ), 'ilike', '%'.$params->query.'%') + ->orWhere(OrderDomainObjectAbstract::LAST_NAME, 'ilike', '%'.$params->query.'%') + ->orWhere(OrderDomainObjectAbstract::PUBLIC_ID, 'ilike', '%'.$params->query.'%') + ->orWhere(OrderDomainObjectAbstract::EMAIL, 'ilike', '%'.$params->query.'%'); }; } - if (!empty($params->filter_fields)) { + if (! empty($params->filter_fields)) { $this->applyFilterFields($params, OrderDomainObject::getAllowedFilterFields()); } @@ -104,7 +111,7 @@ public function findByOrganizerId(int $organizerId, int $accountId, QueryParamsD $sortBy = $this->validateSortColumn($params->sort_by, OrderDomainObject::class); $this->model = $this->model->orderBy( - column: 'orders.' . $sortBy, + column: 'orders.'.$sortBy, direction: $this->validateSortDirection($params->sort_direction, OrderDomainObject::class), ); @@ -138,10 +145,6 @@ public function addOrderItem(array $data): OrderItemDomainObject return $this->handleSingleResult($orderItem, OrderItemDomainObject::class); } - /** - * @param string $orderShortId - * @return OrderDomainObject|null - */ public function findByShortId(string $orderShortId): ?OrderDomainObject { return $this->findFirstByField('short_id', $orderShortId); @@ -157,24 +160,43 @@ protected function getModel(): string return Order::class; } - public function findOrdersAssociatedWithProducts(int $eventId, array $productIds, array $orderStatuses): Collection - { - return $this->handleResults( - $this->model - ->whereHas('order_items', static function (Builder $query) use ($productIds) { - $query->whereIn('product_id', $productIds); - }) - ->whereIn('status', $orderStatuses) - ->where('event_id', $eventId) - ->get() - ); + public function findOrdersAssociatedWithProducts( + int $eventId, + array $productIds, + array $orderStatuses, + ?int $eventOccurrenceId = null, + ?array $eventOccurrenceIds = null, + ): Collection { + $query = $this->model + ->whereHas('order_items', static function (Builder $query) use ($productIds, $eventOccurrenceId, $eventOccurrenceIds) { + $query->whereIn('product_id', $productIds); + if (! empty($eventOccurrenceIds)) { + $query->whereIn('order_items.event_occurrence_id', $eventOccurrenceIds); + } elseif ($eventOccurrenceId !== null) { + $query->where('order_items.event_occurrence_id', $eventOccurrenceId); + } + }) + ->whereIn('status', $orderStatuses) + ->where('event_id', $eventId); + + return $this->handleResults($query->get()); } - public function countOrdersAssociatedWithProducts(int $eventId, array $productIds, array $orderStatuses): int - { + public function countOrdersAssociatedWithProducts( + int $eventId, + array $productIds, + array $orderStatuses, + ?int $eventOccurrenceId = null, + ?array $eventOccurrenceIds = null, + ): int { $count = $this->model - ->whereHas('order_items', static function (Builder $query) use ($productIds) { + ->whereHas('order_items', static function (Builder $query) use ($productIds, $eventOccurrenceId, $eventOccurrenceIds) { $query->whereIn('product_id', $productIds); + if (! empty($eventOccurrenceIds)) { + $query->whereIn('order_items.event_occurrence_id', $eventOccurrenceIds); + } elseif ($eventOccurrenceId !== null) { + $query->where('order_items.event_occurrence_id', $eventOccurrenceId); + } }) ->whereIn('status', $orderStatuses) ->where('event_id', $eventId) @@ -198,11 +220,11 @@ public function getAllOrdersForAdmin( if ($search) { $this->model = $this->model->where(function ($q) use ($search) { - $q->where(OrderDomainObjectAbstract::EMAIL, 'ilike', '%' . $search . '%') - ->orWhere(OrderDomainObjectAbstract::FIRST_NAME, 'ilike', '%' . $search . '%') - ->orWhere(OrderDomainObjectAbstract::LAST_NAME, 'ilike', '%' . $search . '%') - ->orWhere(OrderDomainObjectAbstract::PUBLIC_ID, 'ilike', '%' . $search . '%') - ->orWhere(OrderDomainObjectAbstract::SHORT_ID, 'ilike', '%' . $search . '%'); + $q->where(OrderDomainObjectAbstract::EMAIL, 'ilike', '%'.$search.'%') + ->orWhere(OrderDomainObjectAbstract::FIRST_NAME, 'ilike', '%'.$search.'%') + ->orWhere(OrderDomainObjectAbstract::LAST_NAME, 'ilike', '%'.$search.'%') + ->orWhere(OrderDomainObjectAbstract::PUBLIC_ID, 'ilike', '%'.$search.'%') + ->orWhere(OrderDomainObjectAbstract::SHORT_ID, 'ilike', '%'.$search.'%'); }); } @@ -213,10 +235,10 @@ public function getAllOrdersForAdmin( $sortColumn = in_array($sortBy, $allowedSortColumns, true) ? $sortBy : 'created_at'; $sortDir = in_array(strtolower($sortDirection), ['asc', 'desc']) ? $sortDirection : 'desc'; - $this->model = $this->model->orderBy('orders.' . $sortColumn, $sortDir); + $this->model = $this->model->orderBy('orders.'.$sortColumn, $sortDir); $this->loadRelation(new Relationship(EventDomainObject::class, nested: [ - new Relationship(AccountDomainObject::class, name: 'account') + new Relationship(AccountDomainObject::class, name: 'account'), ], name: 'event')); return $this->paginate($perPage); diff --git a/backend/app/Repository/Eloquent/OrganizerConfigurationRepository.php b/backend/app/Repository/Eloquent/OrganizerConfigurationRepository.php new file mode 100644 index 0000000000..4256634bb0 --- /dev/null +++ b/backend/app/Repository/Eloquent/OrganizerConfigurationRepository.php @@ -0,0 +1,23 @@ + + */ +class OrganizerConfigurationRepository extends BaseRepository implements OrganizerConfigurationRepositoryInterface +{ + protected function getModel(): string + { + return OrganizerConfiguration::class; + } + + public function getDomainObject(): string + { + return OrganizerConfigurationDomainObject::class; + } +} diff --git a/backend/app/Repository/Eloquent/OrganizerStripePlatformRepository.php b/backend/app/Repository/Eloquent/OrganizerStripePlatformRepository.php new file mode 100644 index 0000000000..640263e42b --- /dev/null +++ b/backend/app/Repository/Eloquent/OrganizerStripePlatformRepository.php @@ -0,0 +1,47 @@ + + */ +class OrganizerStripePlatformRepository extends BaseRepository implements OrganizerStripePlatformRepositoryInterface +{ + protected function getModel(): string + { + return OrganizerStripePlatform::class; + } + + public function getDomainObject(): string + { + return OrganizerStripePlatformDomainObject::class; + } + + public function findReusableForAccount(int $accountId, int $excludeOrganizerId, ?string $excludeStripeAccountId): Collection + { + return $this->db->table('organizer_stripe_platforms') + ->join('organizers', 'organizer_stripe_platforms.organizer_id', '=', 'organizers.id') + ->whereNotNull('organizer_stripe_platforms.stripe_setup_completed_at') + ->whereNull('organizer_stripe_platforms.deleted_at') + ->whereNull('organizers.deleted_at') + ->where('organizers.account_id', $accountId) + ->where('organizer_stripe_platforms.organizer_id', '!=', $excludeOrganizerId) + ->when($excludeStripeAccountId !== null, fn($q) => $q->where('organizer_stripe_platforms.stripe_account_id', '!=', $excludeStripeAccountId)) + ->whereNotNull('organizer_stripe_platforms.stripe_account_id') + ->select([ + 'organizer_stripe_platforms.organizer_id as organizer_id', + 'organizer_stripe_platforms.stripe_account_id as stripe_account_id', + 'organizer_stripe_platforms.stripe_connect_platform as stripe_connect_platform', + 'organizer_stripe_platforms.stripe_account_details as stripe_account_details', + 'organizers.name as organizer_name', + ]) + ->get(); + } +} diff --git a/backend/app/Repository/Eloquent/OrganizerVatSettingRepository.php b/backend/app/Repository/Eloquent/OrganizerVatSettingRepository.php new file mode 100644 index 0000000000..ff212dfaef --- /dev/null +++ b/backend/app/Repository/Eloquent/OrganizerVatSettingRepository.php @@ -0,0 +1,28 @@ + + */ +class OrganizerVatSettingRepository extends BaseRepository implements OrganizerVatSettingRepositoryInterface +{ + protected function getModel(): string + { + return OrganizerVatSetting::class; + } + + public function getDomainObject(): string + { + return OrganizerVatSettingDomainObject::class; + } + + public function findByOrganizerId(int $organizerId): ?OrganizerVatSettingDomainObject + { + return $this->findFirstWhere(['organizer_id' => $organizerId]); + } +} diff --git a/backend/app/Repository/Eloquent/ProductOccurrenceVisibilityRepository.php b/backend/app/Repository/Eloquent/ProductOccurrenceVisibilityRepository.php new file mode 100644 index 0000000000..4afe65603a --- /dev/null +++ b/backend/app/Repository/Eloquent/ProductOccurrenceVisibilityRepository.php @@ -0,0 +1,20 @@ +selectRaw(" + $query = DB::table('waitlist_entries') + ->selectRaw(' COUNT(*) as total, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as waiting, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as offered, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as purchased, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as cancelled, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as expired - ", [ + ', [ WaitlistEntryStatus::WAITING->name, WaitlistEntryStatus::OFFERED->name, WaitlistEntryStatus::PURCHASED->name, @@ -50,8 +51,11 @@ public function getStatsByEventId(int $eventId): WaitlistStatsDTO WaitlistEntryStatus::OFFER_EXPIRED->name, ]) ->where('event_id', $eventId) - ->whereNull('deleted_at') - ->first(); + ->whereNull('deleted_at'); + + $this->applyOccurrenceScope($query, $eventOccurrenceId); + + $stats = $query->first(); return new WaitlistStatsDTO( total: (int) ($stats->total ?? 0), @@ -63,9 +67,9 @@ public function getStatsByEventId(int $eventId): WaitlistStatsDTO ); } - public function getProductStatsByEventId(int $eventId): \Illuminate\Support\Collection + public function getProductStatsByEventId(int $eventId, ?int $eventOccurrenceId = null): \Illuminate\Support\Collection { - return DB::table('waitlist_entries') + $query = DB::table('waitlist_entries') ->join('product_prices', 'waitlist_entries.product_price_id', '=', 'product_prices.id') ->join('products', 'product_prices.product_id', '=', 'products.id') ->selectRaw(" @@ -85,41 +89,56 @@ public function getProductStatsByEventId(int $eventId): \Illuminate\Support\Coll ->whereNull('waitlist_entries.deleted_at') ->whereNull('product_prices.deleted_at') ->whereNull('products.deleted_at') - ->groupBy('waitlist_entries.product_price_id', 'products.title', 'product_prices.label') - ->get(); + ->groupBy('waitlist_entries.product_price_id', 'products.title', 'product_prices.label'); + + $this->applyOccurrenceScope($query, $eventOccurrenceId); + + return $query->get(); } - public function getMaxPosition(int $productPriceId): int + public function getMaxPosition(int $productPriceId, ?int $eventOccurrenceId = null): int { - return (int) DB::table('waitlist_entries') + $query = DB::table('waitlist_entries') ->where('product_price_id', $productPriceId) - ->whereNull('deleted_at') - ->max('position') ?? 0; + ->whereNull('deleted_at'); + + $this->applyOccurrenceScope($query, $eventOccurrenceId); + + return (int) $query->max('position') ?? 0; } /** * @return \Illuminate\Support\Collection */ - public function getNextWaitingEntries(int $productPriceId, int $limit): \Illuminate\Support\Collection + public function getNextWaitingEntries(int $productPriceId, ?int $limit = null, ?int $eventOccurrenceId = null): \Illuminate\Support\Collection { - $models = WaitlistEntry::query() + $query = WaitlistEntry::query() ->where('product_price_id', $productPriceId) ->where('status', WaitlistEntryStatus::WAITING->name) ->orderBy('position') - ->limit($limit) - ->get(); + ->orderBy('created_at') + ->orderBy('id'); + + $this->applyOccurrenceScope($query, $eventOccurrenceId); + + if ($limit !== null) { + $query->limit($limit); + } + + $models = $query->get(); return $this->handleResults($models); } - public function lockForProductPrice(int $productPriceId): void + public function lockForProductPrice(int $productPriceId, ?int $eventOccurrenceId = null): void { - DB::table('waitlist_entries') + $query = DB::table('waitlist_entries') ->where('product_price_id', $productPriceId) - ->whereIn('status', [WaitlistEntryStatus::WAITING->name, WaitlistEntryStatus::OFFERED->name]) - ->lockForUpdate() - ->select('id') - ->get(); + ->whereIn('status', [WaitlistEntryStatus::WAITING->name, WaitlistEntryStatus::OFFERED->name]); + + $this->applyOccurrenceScope($query, $eventOccurrenceId); + + $query->lockForUpdate()->select('id')->get(); } public function findByIdLocked(int $id): ?WaitlistEntryDomainObject @@ -145,13 +164,13 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware if ($params->query) { $where[] = static function (Builder $builder) use ($params) { $builder - ->where(WaitlistEntryDomainObjectAbstract::FIRST_NAME, 'ilike', '%' . $params->query . '%') - ->orWhere(WaitlistEntryDomainObjectAbstract::LAST_NAME, 'ilike', '%' . $params->query . '%') - ->orWhere(WaitlistEntryDomainObjectAbstract::EMAIL, 'ilike', '%' . $params->query . '%'); + ->where(WaitlistEntryDomainObjectAbstract::FIRST_NAME, 'ilike', '%'.$params->query.'%') + ->orWhere(WaitlistEntryDomainObjectAbstract::LAST_NAME, 'ilike', '%'.$params->query.'%') + ->orWhere(WaitlistEntryDomainObjectAbstract::EMAIL, 'ilike', '%'.$params->query.'%'); }; } - if (!empty($params->filter_fields)) { + if (! empty($params->filter_fields)) { $this->applyFilterFields($params, WaitlistEntryDomainObject::getAllowedFilterFields()); } @@ -161,9 +180,9 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware ); return $this->loadRelation(new Relationship( - domainObject: OrderDomainObject::class, - name: OrderDomainObjectAbstract::SINGULAR_NAME, - )) + domainObject: OrderDomainObject::class, + name: OrderDomainObjectAbstract::SINGULAR_NAME, + )) ->loadRelation(new Relationship( domainObject: ProductPriceDomainObject::class, nested: [ @@ -174,10 +193,23 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware ], name: ProductPriceDomainObjectAbstract::SINGULAR_NAME )) + ->loadRelation(new Relationship( + domainObject: EventOccurrenceDomainObject::class, + name: 'event_occurrence', + )) ->paginateWhere( where: $where, limit: $params->per_page, page: $params->page, ); } + + private function applyOccurrenceScope($query, ?int $eventOccurrenceId): void + { + if ($eventOccurrenceId === null) { + return; + } + + $query->where('event_occurrence_id', $eventOccurrenceId); + } } diff --git a/backend/app/Repository/Eloquent/WebhookRepository.php b/backend/app/Repository/Eloquent/WebhookRepository.php index dffca0b3b5..d7a6129c72 100644 --- a/backend/app/Repository/Eloquent/WebhookRepository.php +++ b/backend/app/Repository/Eloquent/WebhookRepository.php @@ -25,21 +25,21 @@ public function getDomainObject(): string public function findEnabledByEventId(int $eventId): Collection { - $results = $this->model::query() - ->where('status', WebhookStatus::ENABLED->name) - ->where(function ($query) use ($eventId) { - $query->where('event_id', $eventId) - ->orWhere('organizer_id', function ($subquery) use ($eventId) { - $subquery->select('organizer_id') - ->from('events') - ->where('id', $eventId) - ->limit(1); - }); - }) - ->get(); + return $this->runQuery(function () use ($eventId) { + $results = $this->model::query() + ->where('status', WebhookStatus::ENABLED->name) + ->where(function ($query) use ($eventId) { + $query->where('event_id', $eventId) + ->orWhere('organizer_id', function ($subquery) use ($eventId) { + $subquery->select('organizer_id') + ->from('events') + ->where('id', $eventId) + ->limit(1); + }); + }) + ->get(); - $this->resetModel(); - - return $this->handleResults($results); + return $this->handleResults($results); + }); } } diff --git a/backend/app/Repository/Interfaces/AccountStripePlatformRepositoryInterface.php b/backend/app/Repository/Interfaces/AccountStripePlatformRepositoryInterface.php deleted file mode 100644 index 8276d490f0..0000000000 --- a/backend/app/Repository/Interfaces/AccountStripePlatformRepositoryInterface.php +++ /dev/null @@ -1,14 +0,0 @@ - - */ -interface AccountStripePlatformRepositoryInterface extends RepositoryInterface -{ -} \ No newline at end of file diff --git a/backend/app/Repository/Interfaces/AccountVatSettingRepositoryInterface.php b/backend/app/Repository/Interfaces/AccountVatSettingRepositoryInterface.php deleted file mode 100644 index ea15229e00..0000000000 --- a/backend/app/Repository/Interfaces/AccountVatSettingRepositoryInterface.php +++ /dev/null @@ -1,13 +0,0 @@ - - */ -interface AccountVatSettingRepositoryInterface extends RepositoryInterface -{ - public function findByAccountId(int $accountId): ?AccountVatSettingDomainObject; -} diff --git a/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php b/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php index e176a4ce54..1ce6e9c538 100644 --- a/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php @@ -15,7 +15,7 @@ interface AttendeeRepositoryInterface extends RepositoryInterface { public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator; - public function findByEventIdForExport(int $eventId): Collection; + public function findByEventIdForExport(int $eventId, ?int $eventOccurrenceId = null): Collection; public function getAttendeesByCheckInShortId(string $shortId, QueryParamsDTO $params): Paginator; } diff --git a/backend/app/Repository/Interfaces/CheckInListRepositoryInterface.php b/backend/app/Repository/Interfaces/CheckInListRepositoryInterface.php index 787e734a4c..b2c8df69d8 100644 --- a/backend/app/Repository/Interfaces/CheckInListRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/CheckInListRepositoryInterface.php @@ -5,6 +5,8 @@ use HiEvents\DomainObjects\CheckInListDomainObject; use HiEvents\Http\DTO\QueryParamsDTO; use HiEvents\Repository\DTO\CheckedInAttendeesCountDTO; +use HiEvents\Repository\DTO\CheckInListProductStatDTO; +use HiEvents\Repository\DTO\CheckInListRecentCheckInDTO; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; @@ -15,12 +17,42 @@ interface CheckInListRepositoryInterface extends RepositoryInterface { public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator; - public function getCheckedInAttendeeCountById(int $checkInListId): CheckedInAttendeesCountDTO; + /** + * Counts attendees + check-ins for a check-in list. Auto-scopes to the + * list's own event_occurrence_id when set; callers can override via + * $eventOccurrenceIdOverride (e.g. when an "All occurrences" list is + * further narrowed by the client's filter pill). + */ + public function getCheckedInAttendeeCountById( + int $checkInListId, + ?int $eventOccurrenceIdOverride = null, + ): CheckedInAttendeesCountDTO; /** + * Bulk counterpart used by the admin lists view. Auto-scopes each list + * to its own event_occurrence_id — no override because each row has + * independent scope. + * * @param array $checkInListIds * * @return Collection */ public function getCheckedInAttendeeCountByIds(array $checkInListIds): Collection; + + /** + * @return Collection + */ + public function getPerProductCheckInStatsById( + int $checkInListId, + ?int $eventOccurrenceIdOverride = null, + ): Collection; + + /** + * @return Collection + */ + public function getRecentCheckInsById( + int $checkInListId, + int $limit, + ?int $eventOccurrenceIdOverride = null, + ): Collection; } diff --git a/backend/app/Repository/Interfaces/EventOccurrenceDailyStatisticRepositoryInterface.php b/backend/app/Repository/Interfaces/EventOccurrenceDailyStatisticRepositoryInterface.php new file mode 100644 index 0000000000..98cd9f8338 --- /dev/null +++ b/backend/app/Repository/Interfaces/EventOccurrenceDailyStatisticRepositoryInterface.php @@ -0,0 +1,13 @@ + + */ +interface EventOccurrenceDailyStatisticRepositoryInterface extends RepositoryInterface +{ + +} diff --git a/backend/app/Repository/Interfaces/EventOccurrenceRepositoryInterface.php b/backend/app/Repository/Interfaces/EventOccurrenceRepositoryInterface.php new file mode 100644 index 0000000000..175fce0b4e --- /dev/null +++ b/backend/app/Repository/Interfaces/EventOccurrenceRepositoryInterface.php @@ -0,0 +1,22 @@ + + */ +interface EventOccurrenceRepositoryInterface extends RepositoryInterface +{ + public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator; + + /** + * Acquires a row-level lock on the occurrence (SELECT ... FOR UPDATE) so callers can + * safely read-then-update without losing concurrent state changes. Must be called + * inside a database transaction. + */ + public function findByIdLocked(int $id): ?EventOccurrenceDomainObject; +} diff --git a/backend/app/Repository/Interfaces/EventOccurrenceStatisticRepositoryInterface.php b/backend/app/Repository/Interfaces/EventOccurrenceStatisticRepositoryInterface.php new file mode 100644 index 0000000000..1d6e2464df --- /dev/null +++ b/backend/app/Repository/Interfaces/EventOccurrenceStatisticRepositoryInterface.php @@ -0,0 +1,13 @@ + + */ +interface EventOccurrenceStatisticRepositoryInterface extends RepositoryInterface +{ + +} diff --git a/backend/app/Repository/Interfaces/EventRepositoryInterface.php b/backend/app/Repository/Interfaces/EventRepositoryInterface.php index 7f04c4277c..0486a79424 100644 --- a/backend/app/Repository/Interfaces/EventRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/EventRepositoryInterface.php @@ -29,4 +29,6 @@ public function getAllEventsForAdmin( public function getSitemapEvents(int $page, int $perPage): LengthAwarePaginator; public function getSitemapEventCount(): int; + + public function findByIdLocked(int $id): EventDomainObject; } diff --git a/backend/app/Repository/Interfaces/OrderItemRepositoryInterface.php b/backend/app/Repository/Interfaces/OrderItemRepositoryInterface.php index 6360d893e9..1d47427ca5 100644 --- a/backend/app/Repository/Interfaces/OrderItemRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/OrderItemRepositoryInterface.php @@ -9,4 +9,10 @@ */ interface OrderItemRepositoryInterface extends RepositoryInterface { + /** + * Returns the total quantity reserved (orders in RESERVED status, still within their + * reservation window) against a specific event occurrence. Used by checkout capacity + * validation to subtract pending reservations from the available pool. + */ + public function getReservedQuantityForOccurrence(int $occurrenceId): int; } diff --git a/backend/app/Repository/Interfaces/OrderRepositoryInterface.php b/backend/app/Repository/Interfaces/OrderRepositoryInterface.php index 58cf2c7a8e..dbcf3f61ae 100644 --- a/backend/app/Repository/Interfaces/OrderRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/OrderRepositoryInterface.php @@ -27,9 +27,21 @@ public function addOrderItem(array $data): OrderItemDomainObject; public function findByShortId(string $orderShortId): ?OrderDomainObject; - public function findOrdersAssociatedWithProducts(int $eventId, array $productIds, array $orderStatuses): Collection; - - public function countOrdersAssociatedWithProducts(int $eventId, array $productIds, array $orderStatuses): int; + public function findOrdersAssociatedWithProducts( + int $eventId, + array $productIds, + array $orderStatuses, + ?int $eventOccurrenceId = null, + ?array $eventOccurrenceIds = null, + ): Collection; + + public function countOrdersAssociatedWithProducts( + int $eventId, + array $productIds, + array $orderStatuses, + ?int $eventOccurrenceId = null, + ?array $eventOccurrenceIds = null, + ): int; public function getAllOrdersForAdmin( ?string $search = null, diff --git a/backend/app/Repository/Interfaces/OrganizerConfigurationRepositoryInterface.php b/backend/app/Repository/Interfaces/OrganizerConfigurationRepositoryInterface.php new file mode 100644 index 0000000000..e4c89acfaa --- /dev/null +++ b/backend/app/Repository/Interfaces/OrganizerConfigurationRepositoryInterface.php @@ -0,0 +1,12 @@ + + */ +interface OrganizerConfigurationRepositoryInterface extends RepositoryInterface +{ +} diff --git a/backend/app/Repository/Interfaces/OrganizerStripePlatformRepositoryInterface.php b/backend/app/Repository/Interfaces/OrganizerStripePlatformRepositoryInterface.php new file mode 100644 index 0000000000..f6277e15e0 --- /dev/null +++ b/backend/app/Repository/Interfaces/OrganizerStripePlatformRepositoryInterface.php @@ -0,0 +1,22 @@ + + */ +interface OrganizerStripePlatformRepositoryInterface extends RepositoryInterface +{ + /** + * List completed Stripe Connect rows in $accountId, excluding $excludeOrganizerId + * and the currently-active Stripe account (so the FE can offer "reuse this connection"). + * + * @return Collection + */ + public function findReusableForAccount(int $accountId, int $excludeOrganizerId, ?string $excludeStripeAccountId): Collection; +} diff --git a/backend/app/Repository/Interfaces/OrganizerVatSettingRepositoryInterface.php b/backend/app/Repository/Interfaces/OrganizerVatSettingRepositoryInterface.php new file mode 100644 index 0000000000..dcc001532c --- /dev/null +++ b/backend/app/Repository/Interfaces/OrganizerVatSettingRepositoryInterface.php @@ -0,0 +1,13 @@ + + */ +interface OrganizerVatSettingRepositoryInterface extends RepositoryInterface +{ + public function findByOrganizerId(int $organizerId): ?OrganizerVatSettingDomainObject; +} diff --git a/backend/app/Repository/Interfaces/ProductOccurrenceVisibilityRepositoryInterface.php b/backend/app/Repository/Interfaces/ProductOccurrenceVisibilityRepositoryInterface.php new file mode 100644 index 0000000000..1004c36330 --- /dev/null +++ b/backend/app/Repository/Interfaces/ProductOccurrenceVisibilityRepositoryInterface.php @@ -0,0 +1,12 @@ + + */ +interface ProductOccurrenceVisibilityRepositoryInterface extends RepositoryInterface +{ +} diff --git a/backend/app/Repository/Interfaces/ProductPriceOccurrenceOverrideRepositoryInterface.php b/backend/app/Repository/Interfaces/ProductPriceOccurrenceOverrideRepositoryInterface.php new file mode 100644 index 0000000000..dfecd69121 --- /dev/null +++ b/backend/app/Repository/Interfaces/ProductPriceOccurrenceOverrideRepositoryInterface.php @@ -0,0 +1,12 @@ + + */ +interface ProductPriceOccurrenceOverrideRepositoryInterface extends RepositoryInterface +{ +} diff --git a/backend/app/Repository/Interfaces/RepositoryInterface.php b/backend/app/Repository/Interfaces/RepositoryInterface.php index e6157bf732..f4727e5d31 100644 --- a/backend/app/Repository/Interfaces/RepositoryInterface.php +++ b/backend/app/Repository/Interfaces/RepositoryInterface.php @@ -36,75 +36,57 @@ interface RepositoryInterface public function getDomainObject(): string; /** - * @param array $columns * @return Collection */ public function all(array $columns = self::DEFAULT_COLUMNS): Collection; /** - * @param int $limit - * @param array $columns * @return LengthAwarePaginator */ public function paginate( - int $limit = self::DEFAULT_PAGINATE_LIMIT, + int $limit = self::DEFAULT_PAGINATE_LIMIT, array $columns = self::DEFAULT_COLUMNS ): LengthAwarePaginator; /** - * @param array $where - * @param int $limit - * @param array $columns * @return LengthAwarePaginator */ public function paginateWhere( array $where, - int $limit = self::DEFAULT_PAGINATE_LIMIT, + int $limit = self::DEFAULT_PAGINATE_LIMIT, array $columns = self::DEFAULT_COLUMNS ): LengthAwarePaginator; /** - * @param array $where - * @param int|null $limit - * @param array $columns * @return LengthAwarePaginator */ public function simplePaginateWhere( array $where, - ?int $limit = null, + ?int $limit = null, array $columns = self::DEFAULT_COLUMNS, ): Paginator; /** - * @param Relation $relation - * @param int $limit - * @param array $columns * @return LengthAwarePaginator */ public function paginateEloquentRelation( Relation $relation, - int $limit = self::DEFAULT_PAGINATE_LIMIT, - array $columns = self::DEFAULT_COLUMNS + int $limit = self::DEFAULT_PAGINATE_LIMIT, + array $columns = self::DEFAULT_COLUMNS ): LengthAwarePaginator; /** - * @param int $id - * @param array $columns * @return T */ public function findById(int $id, array $columns = self::DEFAULT_COLUMNS): DomainObjectInterface; /** - * @param int $id - * @param array $columns * @return T|null */ public function findFirst(int $id, array $columns = self::DEFAULT_COLUMNS): ?DomainObjectInterface; /** - * @param array $where - * @param array $columns - * @param OrderAndDirection[] $orderAndDirections + * @param OrderAndDirection[] $orderAndDirections * @return Collection */ public function findWhere( @@ -112,74 +94,53 @@ public function findWhere( array $columns = self::DEFAULT_COLUMNS, /** @var OrderAndDirection[] */ array $orderAndDirections = [], + ?int $limit = null, ): Collection; /** - * @param array $where - * @param array $columns * @return T|null */ public function findFirstWhere(array $where, array $columns = self::DEFAULT_COLUMNS): ?DomainObjectInterface; /** - * @param string $field - * @param string|null $value - * @param array $columns * @return T|null */ public function findFirstByField( - string $field, + string $field, ?string $value = null, - array $columns = ['*'] + array $columns = ['*'] ): ?DomainObjectInterface; /** - * @param string $field - * @param array $values - * @param array $additionalWhere - * @param array $columns * @return Collection + * * @throws Exception */ public function findWhereIn(string $field, array $values, array $additionalWhere = [], array $columns = self::DEFAULT_COLUMNS): Collection; /** - * @param array $attributes * @return T */ public function create(array $attributes): DomainObjectInterface; - /** - * @param array $inserts - * @return bool - */ public function insert(array $inserts): bool; /** - * @param int $id - * @param DomainObjectInterface $domainObject * @return T */ public function updateFromDomainObject(int $id, DomainObjectInterface $domainObject): DomainObjectInterface; /** - * @param int $id - * @param array $attributes * @return T */ public function updateFromArray(int $id, array $attributes): DomainObjectInterface; /** - * @param array $attributes - * @param array $where * @return int Number of affected rows */ public function updateWhere(array $attributes, array $where): int; /** - * @param int $id - * @param array $attributes - * @param array $where * @return T */ public function updateByIdWhere(int $id, array $attributes, array $where): DomainObjectInterface; diff --git a/backend/app/Repository/Interfaces/WaitlistEntryRepositoryInterface.php b/backend/app/Repository/Interfaces/WaitlistEntryRepositoryInterface.php index 99d6901c78..4f73b5f185 100644 --- a/backend/app/Repository/Interfaces/WaitlistEntryRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/WaitlistEntryRepositoryInterface.php @@ -15,18 +15,18 @@ interface WaitlistEntryRepositoryInterface extends RepositoryInterface { public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator; - public function getStatsByEventId(int $eventId): WaitlistStatsDTO; + public function getStatsByEventId(int $eventId, ?int $eventOccurrenceId = null): WaitlistStatsDTO; - public function getProductStatsByEventId(int $eventId): Collection; + public function getProductStatsByEventId(int $eventId, ?int $eventOccurrenceId = null): Collection; - public function getMaxPosition(int $productPriceId): int; + public function getMaxPosition(int $productPriceId, ?int $eventOccurrenceId = null): int; /** * @return Collection */ - public function getNextWaitingEntries(int $productPriceId, int $limit): Collection; + public function getNextWaitingEntries(int $productPriceId, ?int $limit = null, ?int $eventOccurrenceId = null): Collection; - public function lockForProductPrice(int $productPriceId): void; + public function lockForProductPrice(int $productPriceId, ?int $eventOccurrenceId = null): void; public function findByIdLocked(int $id): ?WaitlistEntryDomainObject; } diff --git a/backend/app/Resources/Account/AccountResource.php b/backend/app/Resources/Account/AccountResource.php index dcc55f687f..10ea4cd3a5 100644 --- a/backend/app/Resources/Account/AccountResource.php +++ b/backend/app/Resources/Account/AccountResource.php @@ -13,9 +13,6 @@ class AccountResource extends JsonResource { public function toArray(Request $request): array { - $activeStripePlatform = $this->getPrimaryStripePlatform(); - $isHiEvents = config('app.is_hi_events', false); - return [ 'id' => $this->getId(), 'name' => $this->getName(), @@ -25,20 +22,6 @@ public function toArray(Request $request): array 'is_account_email_confirmed' => $this->getAccountVerifiedAt() !== null, 'is_saas_mode_enabled' => config('app.saas_mode_enabled'), - - $this->mergeWhen(config('app.saas_mode_enabled') && $activeStripePlatform, fn() => [ - 'stripe_account_id' => $activeStripePlatform->getStripeAccountId(), - 'stripe_connect_setup_complete' => $activeStripePlatform->getStripeSetupCompletedAt() !== null, - 'stripe_account_details' => $activeStripePlatform->getStripeAccountDetails(), - 'stripe_platform' => $this->getActiveStripePlatform()?->value, - ]), - $this->mergeWhen($isHiEvents, fn() => [ - 'stripe_hi_events_primary_platform' => config('services.stripe.primary_platform') - ]), - - $this->mergeWhen($this->getConfiguration() !== null, fn() => [ - 'configuration' => new AccountConfigurationResource($this->getConfiguration()), - ]), 'requires_manual_verification' => config('app.saas_mode_enabled') && !$this->getIsManuallyVerified(), ]; } diff --git a/backend/app/Resources/Account/AdminAccountDetailResource.php b/backend/app/Resources/Account/AdminAccountDetailResource.php index 6e0b8cb9f4..c9f13da29d 100644 --- a/backend/app/Resources/Account/AdminAccountDetailResource.php +++ b/backend/app/Resources/Account/AdminAccountDetailResource.php @@ -15,9 +15,6 @@ class AdminAccountDetailResource extends BaseResource { public function toArray(Request $request): array { - $configuration = $this->resource->configuration; - $vatSetting = $this->resource->account_vat_setting; - return [ 'id' => $this->resource->id, 'name' => $this->resource->name, @@ -28,25 +25,38 @@ public function toArray(Request $request): array 'updated_at' => $this->resource->updated_at, 'events_count' => $this->resource->events_count ?? 0, 'users_count' => $this->resource->users_count ?? 0, - 'configuration' => $configuration ? [ - 'id' => $configuration->id, - 'name' => $configuration->name, - 'is_system_default' => $configuration->is_system_default, - 'application_fees' => $configuration->application_fees ?? [ - 'percentage' => 0, - 'fixed' => 0, - ], - ] : null, - 'vat_setting' => $vatSetting ? [ - 'id' => $vatSetting->id, - 'vat_registered' => $vatSetting->vat_registered, - 'vat_number' => $vatSetting->vat_number, - 'vat_validated' => $vatSetting->vat_validated, - 'vat_validation_date' => $vatSetting->vat_validation_date, - 'business_name' => $vatSetting->business_name, - 'business_address' => $vatSetting->business_address, - 'vat_country_code' => $vatSetting->vat_country_code, - ] : null, + 'organizers' => $this->resource->organizers + ? $this->resource->organizers->map(function ($organizer) { + $configuration = $organizer->organizer_configuration; + $vatSetting = $organizer->organizer_vat_setting; + + return [ + 'id' => $organizer->id, + 'name' => $organizer->name, + 'configuration' => $configuration ? [ + 'id' => $configuration->id, + 'name' => $configuration->name, + 'is_system_default' => $configuration->is_system_default, + 'application_fees' => $configuration->application_fees ?? [ + 'percentage' => 0, + 'fixed' => 0, + ], + 'bypass_application_fees' => (bool)($configuration->bypass_application_fees ?? false), + ] : null, + 'vat_setting' => $vatSetting ? [ + 'id' => $vatSetting->id, + 'vat_registered' => (bool)$vatSetting->vat_registered, + 'vat_number' => $vatSetting->vat_number, + 'vat_validated' => (bool)$vatSetting->vat_validated, + 'vat_validation_status' => $vatSetting->vat_validation_status, + 'vat_validation_date' => $vatSetting->vat_validation_date, + 'business_name' => $vatSetting->business_name, + 'business_address' => $vatSetting->business_address, + 'vat_country_code' => $vatSetting->vat_country_code, + ] : null, + ]; + })->values() + : [], 'users' => $this->resource->users->map(function ($user) { return [ 'id' => $user->id, diff --git a/backend/app/Resources/Account/Stripe/StripeConnectAccountsResponseResource.php b/backend/app/Resources/Account/Stripe/StripeConnectAccountsResponseResource.php deleted file mode 100644 index e816112267..0000000000 --- a/backend/app/Resources/Account/Stripe/StripeConnectAccountsResponseResource.php +++ /dev/null @@ -1,37 +0,0 @@ - [ - 'id' => $this->account->getId(), - 'stripe_platform' => $this->account->getActiveStripePlatform()?->value, - ], - 'stripe_connect_accounts' => $this->stripeConnectAccounts->map(function (StripeConnectAccountDTO $account) { - return [ - 'stripe_account_id' => $account->stripeAccountId, - 'connect_url' => $account->connectUrl, - 'is_setup_complete' => $account->isSetupComplete, - 'platform' => $account->platform?->value, - 'account_type' => $account->accountType, - 'is_primary' => $account->isPrimary, - 'country' => $account->country, - ]; - })->toArray(), - 'primary_stripe_account_id' => $this->primaryStripeAccountId, - 'has_completed_setup' => $this->hasCompletedSetup, - ]; - } -} diff --git a/backend/app/Resources/Attendee/AttendeeDetailPublicResource.php b/backend/app/Resources/Attendee/AttendeeDetailPublicResource.php new file mode 100644 index 0000000000..67bb26ffab --- /dev/null +++ b/backend/app/Resources/Attendee/AttendeeDetailPublicResource.php @@ -0,0 +1,82 @@ +attendee; + $order = $attendee->getOrder(); + $product = $attendee->getProduct(); + + $occurrence = $attendee->getEventOccurrence(); + + $data = [ + 'id' => $attendee->getId(), + 'public_id' => $attendee->getPublicId(), + 'first_name' => $attendee->getFirstName(), + 'last_name' => $attendee->getLastName(), + 'email' => $attendee->getEmail(), + 'status' => $attendee->getStatus(), + 'product_id' => $attendee->getProductId(), + 'product_title' => $product?->getTitle(), + 'event_occurrence' => $occurrence + ? (new EventOccurrenceResourcePublic($occurrence))->toArray(request()) + : null, + 'check_ins' => $this->currentListCheckIns + ->map(static fn(AttendeeCheckInDomainObject $checkIn) => (new AttendeeCheckInPublicResource($checkIn))->toArray(request())) + ->values() + ->all(), + 'visibility' => [ + 'notes' => $this->showNotes, + 'question_answers' => $this->showQuestionAnswers, + 'order_details' => $this->showOrderDetails, + ], + ]; + + if ($this->showNotes) { + $data['notes'] = $attendee->getNotes(); + } + + if ($this->showQuestionAnswers) { + $data['question_answers'] = array_map( + static fn(QuestionAndAnswerViewDomainObject $qa) => [ + 'question_id' => $qa->getQuestionId(), + 'title' => $qa->getTitle(), + 'answer' => $qa->getAnswer(), + 'belongs_to' => $qa->getBelongsTo(), + ], + $attendee->getQuestionAndAnswerViews()?->all() ?? [], + ); + } + + if ($this->showOrderDetails && $order) { + $data['order'] = [ + 'id' => $order->getId(), + 'public_id' => $order->getPublicId(), + 'short_id' => $order->getShortId(), + 'status' => $order->getStatus(), + 'total_gross' => $order->getTotalGross(), + 'currency' => $order->getCurrency(), + 'first_name' => $order->getFirstName(), + 'last_name' => $order->getLastName(), + 'email' => $order->getEmail(), + 'created_at' => $order->getCreatedAt(), + ]; + } + + return $data; + } +} diff --git a/backend/app/Resources/Attendee/AttendeeResource.php b/backend/app/Resources/Attendee/AttendeeResource.php index 81d2eadbe7..e527765855 100644 --- a/backend/app/Resources/Attendee/AttendeeResource.php +++ b/backend/app/Resources/Attendee/AttendeeResource.php @@ -5,6 +5,7 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\Enums\QuestionBelongsTo; use HiEvents\Resources\CheckInList\AttendeeCheckInResource; +use HiEvents\Resources\EventOccurrence\EventOccurrenceResource; use HiEvents\Resources\Order\OrderResource; use HiEvents\Resources\Question\QuestionAnswerViewResource; use HiEvents\Resources\Product\ProductResource; @@ -30,8 +31,13 @@ public function toArray(Request $request): array 'last_name' => $this->getLastName(), 'public_id' => $this->getPublicId(), 'short_id' => $this->getShortId(), + 'event_occurrence_id' => $this->getEventOccurrenceId(), 'locale' => $this->getLocale(), 'notes' => $this->getNotes(), + 'event_occurrence' => $this->when( + !is_null($this->getEventOccurrence()), + fn() => new EventOccurrenceResource($this->getEventOccurrence()), + ), 'product' => $this->when( !is_null($this->getProduct()), fn() => new ProductResource($this->getProduct()), diff --git a/backend/app/Resources/Attendee/AttendeeResourcePublic.php b/backend/app/Resources/Attendee/AttendeeResourcePublic.php index 1f4ffc1a36..93ef4168cb 100644 --- a/backend/app/Resources/Attendee/AttendeeResourcePublic.php +++ b/backend/app/Resources/Attendee/AttendeeResourcePublic.php @@ -3,6 +3,7 @@ namespace HiEvents\Resources\Attendee; use HiEvents\DomainObjects\AttendeeDomainObject; +use HiEvents\Resources\EventOccurrence\EventOccurrenceResourcePublic; use HiEvents\Resources\Product\ProductMinimalResourcePublic; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -25,6 +26,11 @@ public function toArray(Request $request): array 'product_id' => $this->getProductId(), 'product_price_id' => $this->getProductPriceId(), 'product' => $this->when((bool)$this->getProduct(), fn() => new ProductMinimalResourcePublic($this->getProduct())), + 'event_occurrence_id' => $this->getEventOccurrenceId(), + 'event_occurrence' => $this->when( + (bool)$this->getEventOccurrence(), + fn() => new EventOccurrenceResourcePublic($this->getEventOccurrence()), + ), 'locale' => $this->getLocale(), ]; } diff --git a/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php b/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php index 198bf3f1fe..5be3567bdd 100644 --- a/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php +++ b/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\Resources\CheckInList\AttendeeCheckInPublicResource; +use HiEvents\Resources\EventOccurrence\EventOccurrenceResourcePublic; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -25,6 +26,10 @@ public function toArray(Request $request): array 'status' => $this->getStatus(), 'locale' => $this->getLocale(), 'order_id' => $this->getOrderId(), + 'event_occurrence_id' => $this->getEventOccurrenceId(), + 'event_occurrence' => $this->getEventOccurrence() + ? (new EventOccurrenceResourcePublic($this->getEventOccurrence()))->toArray($request) + : null, $this->mergeWhen($this->getCheckIn() !== null, [ 'check_in' => new AttendeeCheckInPublicResource($this->getCheckIn()), ]), diff --git a/backend/app/Resources/CheckInList/AttendeeCheckInPublicResource.php b/backend/app/Resources/CheckInList/AttendeeCheckInPublicResource.php index 8ae9dba8b8..c6c6b1c80e 100644 --- a/backend/app/Resources/CheckInList/AttendeeCheckInPublicResource.php +++ b/backend/app/Resources/CheckInList/AttendeeCheckInPublicResource.php @@ -19,6 +19,7 @@ public function toArray($request): array 'attendee_id' => $this->getAttendeeId(), 'checked_in_at' => $this->getCreatedAt(), 'order_id' => $this->getOrderId(), + 'event_occurrence_id' => $this->getEventOccurrenceId(), ]; } } diff --git a/backend/app/Resources/CheckInList/AttendeeCheckInResource.php b/backend/app/Resources/CheckInList/AttendeeCheckInResource.php index e9630ba17b..3c5732fdf8 100644 --- a/backend/app/Resources/CheckInList/AttendeeCheckInResource.php +++ b/backend/app/Resources/CheckInList/AttendeeCheckInResource.php @@ -19,6 +19,7 @@ public function toArray($request): array 'check_in_list_id' => $this->getCheckInListId(), 'product_id' => $this->getProductId(), 'event_id' => $this->getEventId(), + 'event_occurrence_id' => $this->getEventOccurrenceId(), 'short_id' => $this->getShortId(), 'created_at' => $this->getCreatedAt(), 'check_in_list' => $this->when( diff --git a/backend/app/Resources/CheckInList/CheckInListResource.php b/backend/app/Resources/CheckInList/CheckInListResource.php index 744f947c70..ec928dae21 100644 --- a/backend/app/Resources/CheckInList/CheckInListResource.php +++ b/backend/app/Resources/CheckInList/CheckInListResource.php @@ -3,6 +3,7 @@ namespace HiEvents\Resources\CheckInList; use HiEvents\DomainObjects\CheckInListDomainObject; +use HiEvents\Resources\EventOccurrence\EventOccurrenceResource; use HiEvents\Resources\Product\ProductResource; use Illuminate\Http\Resources\Json\JsonResource; @@ -20,12 +21,21 @@ public function toArray($request): array 'expires_at' => $this->getExpiresAt(), 'activates_at' => $this->getActivatesAt(), 'short_id' => $this->getShortId(), + 'is_system_default' => $this->getIsSystemDefault(), + 'event_occurrence_id' => $this->getEventOccurrenceId(), 'total_attendees' => $this->getTotalAttendeesCount(), 'checked_in_attendees' => $this->getCheckedInCount(), + 'public_show_attendee_notes' => $this->getPublicShowAttendeeNotes(), + 'public_show_question_answers' => $this->getPublicShowQuestionAnswers(), + 'public_show_order_details' => $this->getPublicShowOrderDetails(), $this->mergeWhen($this->getEvent() !== null, fn() => [ 'is_expired' => $this->isExpired($this->getEvent()->getTimezone()), 'is_active' => $this->isActivated($this->getEvent()->getTimezone()), ]), + 'event_occurrence' => $this->when( + !is_null($this->getEventOccurrence()), + fn() => new EventOccurrenceResource($this->getEventOccurrence()), + ), $this->mergeWhen($this->getProducts() !== null, fn() => [ 'products' => ProductResource::collection($this->getProducts()), ]), diff --git a/backend/app/Resources/CheckInList/CheckInListResourcePublic.php b/backend/app/Resources/CheckInList/CheckInListResourcePublic.php index 7135da87e4..e4676f1389 100644 --- a/backend/app/Resources/CheckInList/CheckInListResourcePublic.php +++ b/backend/app/Resources/CheckInList/CheckInListResourcePublic.php @@ -3,7 +3,9 @@ namespace HiEvents\Resources\CheckInList; use HiEvents\DomainObjects\CheckInListDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\Resources\Event\EventResourcePublic; +use HiEvents\Resources\EventOccurrence\EventOccurrenceResourcePublic; use HiEvents\Resources\Product\ProductMinimalResourcePublic; use Illuminate\Http\Resources\Json\JsonResource; @@ -18,15 +20,34 @@ public function toArray($request): array 'id' => $this->getId(), 'short_id' => $this->getShortId(), 'name' => $this->getName(), + 'is_system_default' => $this->getIsSystemDefault(), + 'event_occurrence_id' => $this->getEventOccurrenceId(), + 'event_occurrence' => $this->getEventOccurrence() + ? (new EventOccurrenceResourcePublic($this->getEventOccurrence()))->toArray($request) + : null, 'description' => $this->getDescription(), 'expires_at' => $this->getExpiresAt(), 'activates_at' => $this->getActivatesAt(), 'total_attendees' => $this->getTotalAttendeesCount(), 'checked_in_attendees' => $this->getCheckedInCount(), + 'public_show_attendee_notes' => $this->getPublicShowAttendeeNotes(), + 'public_show_question_answers' => $this->getPublicShowQuestionAnswers(), + 'public_show_order_details' => $this->getPublicShowOrderDetails(), $this->mergeWhen($this->getEvent() !== null, fn() => [ 'is_expired' => $this->isExpired($this->getEvent()->getTimezone()), 'is_active' => $this->isActivated($this->getEvent()->getTimezone()), 'event' => EventResourcePublic::make($this->getEvent()), + // Unfiltered list (still excludes cancelled) so the staff filter + // pill can show past sessions for reconciliation. EventResourcePublic + // filters to future/active which is wrong for the check-in UI. + 'event_occurrences' => $this->getEvent()->getEventOccurrences() + ? EventOccurrenceResourcePublic::collection( + $this->getEvent()->getEventOccurrences() + ->filter(fn(EventOccurrenceDomainObject $occ) => !$occ->isCancelled()) + ->sortBy(fn(EventOccurrenceDomainObject $occ) => $occ->getStartDate()) + ->values() + ) + : [], ]), $this->mergeWhen($this->getProducts() !== null, fn() => [ 'products' => ProductMinimalResourcePublic::collection($this->getProducts()), diff --git a/backend/app/Resources/CheckInList/CheckInListStatsPublicResource.php b/backend/app/Resources/CheckInList/CheckInListStatsPublicResource.php new file mode 100644 index 0000000000..ab760f9e57 --- /dev/null +++ b/backend/app/Resources/CheckInList/CheckInListStatsPublicResource.php @@ -0,0 +1,41 @@ + $this->totalAttendees, + 'checked_in_attendees' => $this->checkedInAttendees, + 'per_product' => array_map( + static fn(CheckInListProductStatDTO $stat) => [ + 'product_id' => $stat->productId, + 'product_title' => $stat->productTitle, + 'total_attendees' => $stat->totalAttendees, + 'checked_in_attendees' => $stat->checkedInAttendees, + ], + $this->perProduct, + ), + 'recent_check_ins' => array_map( + static fn(CheckInListRecentCheckInDTO $checkIn) => [ + 'attendee_public_id' => $checkIn->attendeePublicId, + 'first_name' => $checkIn->firstName, + 'last_name' => $checkIn->lastName, + 'product_title' => $checkIn->productTitle, + 'checked_in_at' => $checkIn->checkedInAt, + ], + $this->recentCheckIns, + ), + ]; + } +} diff --git a/backend/app/Resources/Event/EventResource.php b/backend/app/Resources/Event/EventResource.php index 5b68ba7bfd..0273185ddd 100644 --- a/backend/app/Resources/Event/EventResource.php +++ b/backend/app/Resources/Event/EventResource.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Resources\BaseResource; +use HiEvents\Resources\EventOccurrence\EventOccurrenceResource; use HiEvents\Resources\Image\ImageResource; use HiEvents\Resources\Organizer\OrganizerResource; use HiEvents\Resources\Product\ProductResource; @@ -24,7 +25,10 @@ public function toArray(Request $request): array 'description' => $this->getDescription(), 'start_date' => $this->getStartDate(), 'end_date' => $this->getEndDate(), + 'next_occurrence_start_date' => $this->getNextOccurrenceStartDate(), 'status' => $this->getStatus(), + 'type' => $this->getType(), + 'recurrence_rule' => $this->getRecurrenceRule(), 'lifecycle_status' => $this->getLifeCycleStatus(), 'currency' => $this->getCurrency(), 'timezone' => $this->getTimezone(), @@ -52,6 +56,10 @@ public function toArray(Request $request): array condition: !is_null($this->getEventStatistics()), value: fn() => new EventStatisticsResource($this->getEventStatistics()) ), + 'occurrences' => $this->when( + condition: !is_null($this->getEventOccurrences()) && $this->getEventOccurrences()->isNotEmpty(), + value: fn() => EventOccurrenceResource::collection($this->getEventOccurrences()), + ), ]; } } diff --git a/backend/app/Resources/Event/EventResourcePublic.php b/backend/app/Resources/Event/EventResourcePublic.php index da969e58f5..8c078c30c0 100644 --- a/backend/app/Resources/Event/EventResourcePublic.php +++ b/backend/app/Resources/Event/EventResourcePublic.php @@ -2,8 +2,11 @@ namespace HiEvents\Resources\Event; +use HiEvents\DomainObjects\Enums\EventType; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\Resources\BaseResource; +use HiEvents\Resources\EventOccurrence\EventOccurrenceResourcePublic; use HiEvents\Resources\Image\ImageResource; use HiEvents\Resources\Organizer\OrganizerResourcePublic; use HiEvents\Resources\ProductCategory\ProductCategoryResourcePublic; @@ -20,8 +23,7 @@ class EventResourcePublic extends BaseResource public function __construct( mixed $resource, mixed $includePostCheckoutData = false, - ) - { + ) { // This is a hacky workaround to handle when this resource is instantiated // internally within Laravel the second param is the collection key (numeric) // When called normally, second param is includePostCheckoutData (boolean) @@ -34,6 +36,8 @@ public function __construct( public function toArray(Request $request): array { + $isRecurring = $this->getType() === EventType::RECURRING->name; + return [ 'id' => $this->getId(), 'title' => $this->getTitle(), @@ -42,38 +46,55 @@ public function toArray(Request $request): array 'description_preview' => $this->getDescriptionPreview(), 'start_date' => $this->getStartDate(), 'end_date' => $this->getEndDate(), + 'next_occurrence_start_date' => $this->getNextOccurrenceStartDate(), + 'type' => $this->getType(), 'currency' => $this->getCurrency(), 'slug' => $this->getSlug(), 'status' => $this->getStatus(), 'lifecycle_status' => $this->getLifecycleStatus(), 'timezone' => $this->getTimezone(), - 'location_details' => $this->when((bool)$this->getLocationDetails(), fn() => $this->getLocationDetails()), + 'location_details' => $this->when((bool) $this->getLocationDetails(), fn () => $this->getLocationDetails()), 'product_categories' => $this->when( - condition: !is_null($this->getProductCategories()) && $this->getProductCategories()->isNotEmpty(), - value: fn() => ProductCategoryResourcePublic::collection($this->getProductCategories()), + condition: ! is_null($this->getProductCategories()) && $this->getProductCategories()->isNotEmpty(), + value: fn () => ProductCategoryResourcePublic::collection($this->getProductCategories()), ), 'settings' => $this->when( - condition: !is_null($this->getEventSettings()), - value: fn() => new EventSettingsResourcePublic( + condition: ! is_null($this->getEventSettings()), + value: fn () => new EventSettingsResourcePublic( $this->getEventSettings(), $this->includePostCheckoutData ), ), // @TODO - public question resource 'questions' => $this->when( - condition: !is_null($this->getQuestions()), - value: fn() => QuestionResource::collection($this->getQuestions()) + condition: ! is_null($this->getQuestions()), + value: fn () => QuestionResource::collection($this->getQuestions()) ), 'attributes' => $this->when( - condition: !is_null($this->getAttributes()), - value: fn() => collect($this->getAttributes())->reject(fn($attribute) => !$attribute['is_public'])), + condition: ! is_null($this->getAttributes()), + value: fn () => collect($this->getAttributes())->reject(fn ($attribute) => ! $attribute['is_public'])), 'images' => $this->when( - condition: !is_null($this->getImages()), - value: fn() => ImageResource::collection($this->getImages()) + condition: ! is_null($this->getImages()), + value: fn () => ImageResource::collection($this->getImages()) ), 'organizer' => $this->when( - condition: !is_null($this->getOrganizer()), - value: fn() => new OrganizerResourcePublic($this->getOrganizer()), + condition: ! is_null($this->getOrganizer()), + value: fn () => new OrganizerResourcePublic($this->getOrganizer()), + ), + 'occurrences' => $this->when( + condition: ! is_null($this->getEventOccurrences()) && $this->getEventOccurrences()->isNotEmpty(), + // Cap is enforced by GetPublicEventHandler before assignment so + // the requested occurrence (which the handler explicitly pushes + // even when its position is past the cap) survives to the + // payload. Re-capping here re-sorted by start_date and dropped + // it again, breaking shared/checkout links to occurrences past + // the first 200 upcoming. + value: fn () => EventOccurrenceResourcePublic::collection( + $this->getEventOccurrences() + ->filter(fn (EventOccurrenceDomainObject $occ) => ! $occ->isCancelled() && (! $isRecurring || ! $occ->isPast())) + ->sortBy(fn (EventOccurrenceDomainObject $occ) => $occ->getStartDate()) + ->values() + ), ), ]; } diff --git a/backend/app/Resources/EventOccurrence/EventOccurrenceResource.php b/backend/app/Resources/EventOccurrence/EventOccurrenceResource.php new file mode 100644 index 0000000000..abb0bcbcd3 --- /dev/null +++ b/backend/app/Resources/EventOccurrence/EventOccurrenceResource.php @@ -0,0 +1,46 @@ +getEventOccurrenceStatistics(); + + return [ + 'id' => $this->getId(), + 'event_id' => $this->getEventId(), + 'short_id' => $this->getShortId(), + 'start_date' => $this->getStartDate(), + 'end_date' => $this->getEndDate(), + 'status' => $this->getStatus(), + 'capacity' => $this->getCapacity(), + 'used_capacity' => $this->getUsedCapacity(), + 'available_capacity' => $this->getAvailableCapacity(), + 'label' => $this->getLabel(), + 'is_overridden' => $this->getIsOverridden(), + 'is_past' => $this->isPast(), + 'is_future' => $this->isFuture(), + 'is_active' => $this->isActive(), + 'statistics' => $this->when($stats !== null, fn() => [ + 'total_gross_sales' => $stats->getSalesTotalGross() ?? 0, + 'total_tax' => $stats->getTotalTax() ?? 0, + 'total_fee' => $stats->getTotalFee() ?? 0, + 'orders_created' => $stats->getOrdersCreated() ?? 0, + 'total_refunded' => $stats->getTotalRefunded() ?? 0, + 'attendees_registered' => $stats->getAttendeesRegistered() ?? 0, + 'products_sold' => $stats->getProductsSold() ?? 0, + ]), + 'created_at' => $this->getCreatedAt(), + 'updated_at' => $this->getUpdatedAt(), + ]; + } +} diff --git a/backend/app/Resources/EventOccurrence/EventOccurrenceResourcePublic.php b/backend/app/Resources/EventOccurrence/EventOccurrenceResourcePublic.php new file mode 100644 index 0000000000..de0671cab4 --- /dev/null +++ b/backend/app/Resources/EventOccurrence/EventOccurrenceResourcePublic.php @@ -0,0 +1,31 @@ + $this->getId(), + 'event_id' => $this->getEventId(), + 'short_id' => $this->getShortId(), + 'start_date' => $this->getStartDate(), + 'end_date' => $this->getEndDate(), + 'status' => $this->getStatus(), + 'capacity' => $this->getCapacity(), + 'available_capacity' => $this->getAvailableCapacity(), + 'label' => $this->getLabel(), + 'is_past' => $this->isPast(), + 'is_future' => $this->isFuture(), + 'is_active' => $this->isActive(), + ]; + } +} diff --git a/backend/app/Resources/EventOccurrence/ProductOccurrenceVisibilityResource.php b/backend/app/Resources/EventOccurrence/ProductOccurrenceVisibilityResource.php new file mode 100644 index 0000000000..e31b27b448 --- /dev/null +++ b/backend/app/Resources/EventOccurrence/ProductOccurrenceVisibilityResource.php @@ -0,0 +1,23 @@ + $this->getId(), + 'event_occurrence_id' => $this->getEventOccurrenceId(), + 'product_id' => $this->getProductId(), + 'created_at' => $this->getCreatedAt(), + ]; + } +} diff --git a/backend/app/Resources/EventOccurrence/ProductPriceOccurrenceOverrideResource.php b/backend/app/Resources/EventOccurrence/ProductPriceOccurrenceOverrideResource.php new file mode 100644 index 0000000000..d096d6334b --- /dev/null +++ b/backend/app/Resources/EventOccurrence/ProductPriceOccurrenceOverrideResource.php @@ -0,0 +1,25 @@ + $this->getId(), + 'event_occurrence_id' => $this->getEventOccurrenceId(), + 'product_price_id' => $this->getProductPriceId(), + 'price' => $this->getPrice(), + 'created_at' => $this->getCreatedAt(), + 'updated_at' => $this->getUpdatedAt(), + ]; + } +} diff --git a/backend/app/Resources/Order/OrderItemResource.php b/backend/app/Resources/Order/OrderItemResource.php index 3199ca498b..e1b3ce4ac4 100644 --- a/backend/app/Resources/Order/OrderItemResource.php +++ b/backend/app/Resources/Order/OrderItemResource.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\Resources\BaseResource; +use HiEvents\Resources\EventOccurrence\EventOccurrenceResource; use Illuminate\Http\Request; /** @@ -20,9 +21,14 @@ public function toArray(Request $request): array 'price' => $this->getPrice(), 'quantity' => $this->getQuantity(), 'product_id' => $this->getProductId(), + 'event_occurrence_id' => $this->getEventOccurrenceId(), 'item_name' => $this->getItemName(), 'price_before_discount' => $this->getPriceBeforeDiscount(), 'taxes_and_fees_rollup' => $this->getTaxesAndFeesRollup(), + 'event_occurrence' => $this->when( + !is_null($this->getEventOccurrence()), + fn() => new EventOccurrenceResource($this->getEventOccurrence()), + ), ]; } } diff --git a/backend/app/Resources/Order/OrderItemResourcePublic.php b/backend/app/Resources/Order/OrderItemResourcePublic.php index 18ff026120..f7c90c4551 100644 --- a/backend/app/Resources/Order/OrderItemResourcePublic.php +++ b/backend/app/Resources/Order/OrderItemResourcePublic.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\Resources\BaseResource; +use HiEvents\Resources\EventOccurrence\EventOccurrenceResourcePublic; use HiEvents\Resources\Product\ProductResourcePublic; use Illuminate\Http\Request; @@ -29,6 +30,11 @@ public function toArray(Request $request): array 'total_tax' => $this->getTotalTax(), 'total_gross' => $this->getTotalGross(), 'taxes_and_fees_rollup' => $this->getTaxesAndFeesRollup(), + 'event_occurrence_id' => $this->getEventOccurrenceId(), + 'event_occurrence' => $this->when( + !is_null($this->getEventOccurrence()), + fn() => new EventOccurrenceResourcePublic($this->getEventOccurrence()), + ), 'product' => $this->when((bool)$this->getProduct(), fn() => new ProductResourcePublic($this->getProduct())), ]; } diff --git a/backend/app/Resources/Account/AccountConfigurationResource.php b/backend/app/Resources/Organizer/OrganizerConfigurationResource.php similarity index 75% rename from backend/app/Resources/Account/AccountConfigurationResource.php rename to backend/app/Resources/Organizer/OrganizerConfigurationResource.php index 548477f479..4521c10c89 100644 --- a/backend/app/Resources/Account/AccountConfigurationResource.php +++ b/backend/app/Resources/Organizer/OrganizerConfigurationResource.php @@ -1,14 +1,14 @@ getOrganizerSettings()), value: fn() => new OrganizerSettingsResource($this->getOrganizerSettings()) ), + $this->mergeWhen( + config('app.saas_mode_enabled') && $this->getOrganizerStripePlatforms() !== null, + fn() => [ + 'stripe_connect_setup_complete' => $this->isStripeSetupComplete(), + 'stripe_account_id' => $this->getActiveStripeAccountId(), + ], + ), + $this->mergeWhen( + $this->getOrganizerConfiguration() !== null, + fn() => [ + 'configuration' => new OrganizerConfigurationResource($this->getOrganizerConfiguration()), + ], + ), ]; } } diff --git a/backend/app/Resources/Account/Stripe/StripeConnectAccountResponseResource.php b/backend/app/Resources/Organizer/Stripe/OrganizerStripeConnectAccountResponseResource.php similarity index 56% rename from backend/app/Resources/Account/Stripe/StripeConnectAccountResponseResource.php rename to backend/app/Resources/Organizer/Stripe/OrganizerStripeConnectAccountResponseResource.php index 9eb5411da4..a9cc9dfb62 100644 --- a/backend/app/Resources/Account/Stripe/StripeConnectAccountResponseResource.php +++ b/backend/app/Resources/Organizer/Stripe/OrganizerStripeConnectAccountResponseResource.php @@ -1,15 +1,15 @@ $this->stripeAccountId, 'is_connect_setup_complete' => $this->isConnectSetupComplete, 'connect_url' => $this->connectUrl, - 'account' => new AccountResource($this->account), + 'organizer' => new OrganizerResource($this->organizer), ]; } } diff --git a/backend/app/Resources/Organizer/Stripe/OrganizerStripeConnectAccountsResponseResource.php b/backend/app/Resources/Organizer/Stripe/OrganizerStripeConnectAccountsResponseResource.php new file mode 100644 index 0000000000..a27c14a530 --- /dev/null +++ b/backend/app/Resources/Organizer/Stripe/OrganizerStripeConnectAccountsResponseResource.php @@ -0,0 +1,54 @@ + [ + 'id' => $this->organizer->getId(), + 'name' => $this->organizer->getName(), + 'stripe_platform' => $this->organizer->getActiveStripePlatform()?->value, + ], + 'stripe_connect_accounts' => $this->stripeConnectAccounts->map(function (StripeConnectAccountDTO $account) { + return [ + 'stripe_account_id' => $account->stripeAccountId, + 'connect_url' => $account->connectUrl, + 'is_setup_complete' => $account->isSetupComplete, + 'platform' => $account->platform?->value, + 'account_type' => $account->accountType, + 'is_primary' => $account->isPrimary, + 'country' => $account->country, + 'business_type' => $account->businessType, + 'charges_enabled' => $account->chargesEnabled, + 'payouts_enabled' => $account->payoutsEnabled, + 'capabilities' => $account->capabilities, + 'requirements' => $account->requirements, + ]; + })->values()->toArray(), + 'reusable_connections' => $this->reusableConnections->map(function (ReusableStripeConnectionDTO $connection) { + return [ + 'organizer_id' => $connection->organizerId, + 'organizer_name' => $connection->organizerName, + 'stripe_account_id' => $connection->stripeAccountId, + 'platform' => $connection->platform, + 'country' => $connection->country, + 'business_type' => $connection->businessType, + ]; + })->values()->toArray(), + 'primary_stripe_account_id' => $this->primaryStripeAccountId, + 'has_completed_setup' => $this->hasCompletedSetup, + ]; + } +} diff --git a/backend/app/Resources/Account/AccountVatSettingResource.php b/backend/app/Resources/Organizer/Vat/OrganizerVatSettingResource.php similarity index 78% rename from backend/app/Resources/Account/AccountVatSettingResource.php rename to backend/app/Resources/Organizer/Vat/OrganizerVatSettingResource.php index c6901bbf9e..aea3d9a8a7 100644 --- a/backend/app/Resources/Account/AccountVatSettingResource.php +++ b/backend/app/Resources/Organizer/Vat/OrganizerVatSettingResource.php @@ -2,21 +2,21 @@ declare(strict_types=1); -namespace HiEvents\Resources\Account; +namespace HiEvents\Resources\Organizer\Vat; -use HiEvents\DomainObjects\AccountVatSettingDomainObject; +use HiEvents\DomainObjects\OrganizerVatSettingDomainObject; use HiEvents\Resources\BaseResource; /** - * @mixin AccountVatSettingDomainObject + * @mixin OrganizerVatSettingDomainObject */ -class AccountVatSettingResource extends BaseResource +class OrganizerVatSettingResource extends BaseResource { public function toArray($request): array { return [ 'id' => $this->getId(), - 'account_id' => $this->getAccountId(), + 'organizer_id' => $this->getOrganizerId(), 'vat_registered' => $this->getVatRegistered(), 'vat_number' => $this->getVatNumber(), 'vat_validated' => $this->getVatValidated(), diff --git a/backend/app/Resources/Waitlist/WaitlistEntryResource.php b/backend/app/Resources/Waitlist/WaitlistEntryResource.php index a2ea76a20b..071a50a08e 100644 --- a/backend/app/Resources/Waitlist/WaitlistEntryResource.php +++ b/backend/app/Resources/Waitlist/WaitlistEntryResource.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\WaitlistEntryDomainObject; use HiEvents\Resources\BaseResource; +use HiEvents\Resources\EventOccurrence\EventOccurrenceResource; use HiEvents\Resources\Product\ProductPriceResource; use HiEvents\Resources\Product\ProductResource; use Illuminate\Http\Request; @@ -19,6 +20,7 @@ public function toArray(Request $request): array 'id' => $this->getId(), 'event_id' => $this->getEventId(), 'product_price_id' => $this->getProductPriceId(), + 'event_occurrence_id' => $this->getEventOccurrenceId(), 'email' => $this->getEmail(), 'first_name' => $this->getFirstName(), 'last_name' => $this->getLastName(), @@ -36,6 +38,9 @@ public function toArray(Request $request): array 'product_price' => $this->getProductPrice() ? new ProductPriceResource($this->getProductPrice()) : null, + 'event_occurrence' => $this->getEventOccurrence() + ? new EventOccurrenceResource($this->getEventOccurrence()) + : null, 'created_at' => $this->getCreatedAt(), 'updated_at' => $this->getUpdatedAt(), ]; diff --git a/backend/app/Services/Application/Handlers/Account/Payment/Stripe/DTO/CreateStripeConnectAccountResponse.php b/backend/app/Services/Application/Handlers/Account/Payment/Stripe/DTO/CreateStripeConnectAccountResponse.php deleted file mode 100644 index 9e0b800c72..0000000000 --- a/backend/app/Services/Application/Handlers/Account/Payment/Stripe/DTO/CreateStripeConnectAccountResponse.php +++ /dev/null @@ -1,19 +0,0 @@ -accountRepository - ->loadRelation(AccountStripePlatformDomainObject::class) - ->findById($accountId); - - $stripeConnectAccounts = $this->getStripeConnectAccounts($account); - $primaryStripeAccountId = $account->getActiveStripeAccountId(); - $hasCompletedSetup = $account->isStripeSetupComplete(); - - return new GetStripeConnectAccountsResponseDTO( - account: $account, - stripeConnectAccounts: $stripeConnectAccounts, - primaryStripeAccountId: $primaryStripeAccountId, - hasCompletedSetup: $hasCompletedSetup, - ); - } - - private function getStripeConnectAccounts(AccountDomainObject $account): Collection - { - $stripeAccounts = collect(); - $stripePlatforms = $account->getAccountStripePlatforms(); - - if (!$stripePlatforms || $stripePlatforms->isEmpty()) { - return $stripeAccounts; - } - - foreach ($stripePlatforms as $stripePlatform) { - $stripeAccount = $this->getStripeAccount($stripePlatform); - if ($stripeAccount) { - $stripeAccounts->push($stripeAccount); - } - } - - return $stripeAccounts; - } - - private function getStripeAccount(AccountStripePlatformDomainObject $stripePlatform): ?StripeConnectAccountDTO - { - if (!$stripePlatform->getStripeAccountId()) { - return null; - } - - try { - $platform = $stripePlatform->getStripeConnectPlatform() - ? StripePlatform::fromString($stripePlatform->getStripeConnectPlatform()) - : null; - - $stripeClient = $this->stripeClientFactory->createForPlatform($platform); - $stripeAccount = $stripeClient->accounts->retrieve($stripePlatform->getStripeAccountId()); - - $isSetupComplete = $this->stripeAccountSyncService->isStripeAccountComplete($stripeAccount); - $connectUrl = null; - - // Check if Stripe says setup is complete but our DB doesn't reflect it - if ($isSetupComplete && $stripePlatform->getStripeSetupCompletedAt() === null) { - $this->stripeAccountSyncService->markAccountAsComplete($stripePlatform, $stripeAccount); - } - - // Generate connect URL if setup is not complete - if (!$isSetupComplete) { - $connectUrl = $this->stripeAccountSyncService->createStripeAccountSetupUrl($stripeAccount, $stripeClient); - } - - return new StripeConnectAccountDTO( - stripeAccountId: $stripeAccount->id, - connectUrl: $connectUrl, - isSetupComplete: $isSetupComplete, - platform: $platform, - accountType: $stripeAccount->type, - isPrimary: $stripePlatform->getStripeSetupCompletedAt() !== null, - country: is_array($stripePlatform->getStripeAccountDetails()) ? ($stripePlatform->getStripeAccountDetails()['country'] ?? null) : null, - ); - } catch (StripeClientConfigurationException $e) { - $this->logger->warning('Failed to retrieve Stripe account due to configuration issue', [ - 'stripe_account_id' => $stripePlatform->getStripeAccountId(), - 'platform' => $stripePlatform->getStripeConnectPlatform(), - 'error' => $e->getMessage(), - ]); - return null; - } catch (Throwable $e) { - $this->logger->error('Failed to retrieve Stripe account', [ - 'stripe_account_id' => $stripePlatform->getStripeAccountId(), - 'platform' => $stripePlatform->getStripeConnectPlatform(), - 'error' => $e->getMessage(), - ]); - return null; - } - } -} diff --git a/backend/app/Services/Application/Handlers/Account/Vat/DTO/UpsertAccountVatSettingDTO.php b/backend/app/Services/Application/Handlers/Account/Vat/DTO/UpsertAccountVatSettingDTO.php deleted file mode 100644 index 3c6168f66f..0000000000 --- a/backend/app/Services/Application/Handlers/Account/Vat/DTO/UpsertAccountVatSettingDTO.php +++ /dev/null @@ -1,15 +0,0 @@ -vatSettingRepository->findByAccountId($accountId); - } -} diff --git a/backend/app/Services/Application/Handlers/Admin/AssignConfigurationHandler.php b/backend/app/Services/Application/Handlers/Admin/AssignConfigurationHandler.php deleted file mode 100644 index 3706b92d12..0000000000 --- a/backend/app/Services/Application/Handlers/Admin/AssignConfigurationHandler.php +++ /dev/null @@ -1,31 +0,0 @@ -configurationRepository->findById($configurationId); - - $this->accountRepository->updateFromArray( - id: $accountId, - attributes: ['account_configuration_id' => $configurationId] - ); - } -} diff --git a/backend/app/Services/Application/Handlers/Admin/DeleteConfigurationHandler.php b/backend/app/Services/Application/Handlers/Admin/DeleteConfigurationHandler.php index 77822d298f..88ab63bf75 100644 --- a/backend/app/Services/Application/Handlers/Admin/DeleteConfigurationHandler.php +++ b/backend/app/Services/Application/Handlers/Admin/DeleteConfigurationHandler.php @@ -5,12 +5,12 @@ namespace HiEvents\Services\Application\Handlers\Admin; use HiEvents\Exceptions\CannotDeleteEntityException; -use HiEvents\Repository\Interfaces\AccountConfigurationRepositoryInterface; +use HiEvents\Repository\Interfaces\OrganizerConfigurationRepositoryInterface; class DeleteConfigurationHandler { public function __construct( - private readonly AccountConfigurationRepositoryInterface $repository, + private readonly OrganizerConfigurationRepositoryInterface $repository, ) { } diff --git a/backend/app/Services/Application/Handlers/Admin/GetAdminDashboardDataHandler.php b/backend/app/Services/Application/Handlers/Admin/GetAdminDashboardDataHandler.php index 591838b14a..be8ac5936a 100644 --- a/backend/app/Services/Application/Handlers/Admin/GetAdminDashboardDataHandler.php +++ b/backend/app/Services/Application/Handlers/Admin/GetAdminDashboardDataHandler.php @@ -125,13 +125,23 @@ private function getTopOrganizers(Carbon $since, int $limit): array private function getRecentAccounts(int $limit): array { + // stripe_connect_setup_complete is computed from organizer_stripe_platforms — + // any organizer in the account with completed setup counts as connected. $query = <<configurationRepository->findById($configurationId); + + $this->organizerRepository->updateFromArray( + id: $organizerId, + attributes: ['organizer_configuration_id' => $configurationId], + ); + } +} diff --git a/backend/app/Services/Application/Handlers/Admin/UpdateAdminAccountVatSettingHandler.php b/backend/app/Services/Application/Handlers/Admin/Organizer/UpdateAdminOrganizerVatSettingHandler.php similarity index 51% rename from backend/app/Services/Application/Handlers/Admin/UpdateAdminAccountVatSettingHandler.php rename to backend/app/Services/Application/Handlers/Admin/Organizer/UpdateAdminOrganizerVatSettingHandler.php index 4971856bf8..aff5ad7806 100644 --- a/backend/app/Services/Application/Handlers/Admin/UpdateAdminAccountVatSettingHandler.php +++ b/backend/app/Services/Application/Handlers/Admin/Organizer/UpdateAdminOrganizerVatSettingHandler.php @@ -1,25 +1,25 @@ vatSettingRepository->findByAccountId($dto->accountId); + $existing = $this->vatSettingRepository->findByOrganizerId($dto->organizerId); $data = [ - 'account_id' => $dto->accountId, + 'organizer_id' => $dto->organizerId, 'vat_registered' => $dto->vatRegistered, 'vat_number' => $dto->vatNumber, 'vat_validated' => $dto->vatValidated ?? false, diff --git a/backend/app/Services/Application/Handlers/Admin/Organizer/UpdateOrganizerConfigurationHandler.php b/backend/app/Services/Application/Handlers/Admin/Organizer/UpdateOrganizerConfigurationHandler.php new file mode 100644 index 0000000000..38c84f6d18 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Admin/Organizer/UpdateOrganizerConfigurationHandler.php @@ -0,0 +1,76 @@ +organizerRepository + ->loadRelation(new Relationship( + domainObject: OrganizerConfigurationDomainObject::class, + name: 'organizer_configuration', + )) + ->findById($dto->organizerId); + + $currentConfiguration = $organizer->getOrganizerConfiguration(); + + // Update in place only when the configuration is dedicated to this organizer. + // The system default and any backfilled/shared row may be referenced by sibling + // organizers, so mutating it would silently change their fees too. + if ($currentConfiguration !== null + && !$currentConfiguration->getIsSystemDefault() + && $this->isConfigurationDedicatedTo($currentConfiguration->getId(), $organizer->getId()) + ) { + return $this->configurationRepository->updateFromArray( + id: $currentConfiguration->getId(), + attributes: ['application_fees' => $dto->applicationFees], + ); + } + + $configuration = $this->configurationRepository->create([ + 'name' => sprintf('%s (#%d) - Custom Fees', $organizer->getName(), $organizer->getId()), + 'is_system_default' => false, + 'application_fees' => $dto->applicationFees, + ]); + + $this->organizerRepository->updateFromArray( + id: $organizer->getId(), + attributes: ['organizer_configuration_id' => $configuration->getId()], + ); + + return $configuration; + } + + private function isConfigurationDedicatedTo(int $configurationId, int $organizerId): bool + { + $totalReferences = $this->organizerRepository->countWhere([ + 'organizer_configuration_id' => $configurationId, + ]); + + if ($totalReferences !== 1) { + return false; + } + + // The single reference must be the organizer we're editing. + $ownReference = $this->organizerRepository->countWhere([ + 'organizer_configuration_id' => $configurationId, + 'id' => $organizerId, + ]); + + return $ownReference === 1; + } +} diff --git a/backend/app/Services/Application/Handlers/Admin/UpdateAccountConfigurationHandler.php b/backend/app/Services/Application/Handlers/Admin/UpdateAccountConfigurationHandler.php deleted file mode 100644 index d398dd5cb2..0000000000 --- a/backend/app/Services/Application/Handlers/Admin/UpdateAccountConfigurationHandler.php +++ /dev/null @@ -1,49 +0,0 @@ -accountRepository - ->loadRelation('configuration') - ->findById($dto->accountId); - - $data = [ - 'application_fees' => $dto->applicationFees, - ]; - - if ($account->getConfiguration()) { - return $this->configurationRepository->updateFromArray( - id: $account->getConfiguration()->getId(), - attributes: $data - ); - } - - $configuration = $this->configurationRepository->create([ - 'name' => 'Account Configuration', - 'is_system_default' => false, - 'application_fees' => $dto->applicationFees, - ]); - - $this->accountRepository->updateFromArray( - id: $account->getId(), - attributes: ['account_configuration_id' => $configuration->getId()] - ); - - return $configuration; - } -} diff --git a/backend/app/Services/Application/Handlers/Attendee/CreateAttendeeHandler.php b/backend/app/Services/Application/Handlers/Attendee/CreateAttendeeHandler.php index 4ca0d841a0..d36b3297d5 100644 --- a/backend/app/Services/Application/Handlers/Attendee/CreateAttendeeHandler.php +++ b/backend/app/Services/Application/Handlers/Attendee/CreateAttendeeHandler.php @@ -4,6 +4,7 @@ use Brick\Money\Money; use HiEvents\DomainObjects\AttendeeDomainObject; +use HiEvents\DomainObjects\Enums\EventType; use HiEvents\DomainObjects\Enums\ProductType; use HiEvents\DomainObjects\Generated\AttendeeDomainObjectAbstract; use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract; @@ -21,39 +22,43 @@ use HiEvents\Exceptions\NoTicketsAvailableException; use HiEvents\Helper\IdHelper; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use HiEvents\Repository\Interfaces\TaxAndFeeRepositoryInterface; use HiEvents\Services\Application\Handlers\Attendee\DTO\CreateAttendeeDTO; use HiEvents\Services\Application\Handlers\Attendee\DTO\CreateAttendeeTaxAndFeeDTO; +use HiEvents\Services\Domain\EventOccurrence\OccurrencePurchaseEligibilityService; use HiEvents\Services\Domain\Order\OrderManagementService; use HiEvents\Services\Domain\Product\ProductQuantityUpdateService; +use HiEvents\Services\Domain\SelfService\OrderAuditLogService; use HiEvents\Services\Domain\Tax\TaxAndFeeRollupService; use HiEvents\Services\Infrastructure\DomainEvents\DomainEventDispatcherService; use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; use HiEvents\Services\Infrastructure\DomainEvents\Events\OrderEvent; use Illuminate\Database\DatabaseManager; use Illuminate\Support\Collection; -use RuntimeException; +use Illuminate\Validation\ValidationException; use Throwable; class CreateAttendeeHandler { public function __construct( - private readonly AttendeeRepositoryInterface $attendeeRepository, - private readonly OrderRepositoryInterface $orderRepository, - private readonly ProductRepositoryInterface $productRepository, - private readonly EventRepositoryInterface $eventRepository, + private readonly AttendeeRepositoryInterface $attendeeRepository, + private readonly OrderRepositoryInterface $orderRepository, + private readonly ProductRepositoryInterface $productRepository, + private readonly EventRepositoryInterface $eventRepository, + private readonly EventOccurrenceRepositoryInterface $eventOccurrenceRepository, private readonly ProductQuantityUpdateService $productQuantityAdjustmentService, - private readonly DatabaseManager $databaseManager, + private readonly DatabaseManager $databaseManager, private readonly TaxAndFeeRepositoryInterface $taxAndFeeRepository, - private readonly TaxAndFeeRollupService $taxAndFeeRollupService, - private readonly OrderManagementService $orderManagementService, + private readonly TaxAndFeeRollupService $taxAndFeeRollupService, + private readonly OrderManagementService $orderManagementService, private readonly DomainEventDispatcherService $domainEventDispatcherService, - ) - { - } + private readonly OccurrencePurchaseEligibilityService $occurrenceEligibilityService, + private readonly OrderAuditLogService $orderAuditLogService, + ) {} /** * @throws NoTicketsAvailableException @@ -61,6 +66,24 @@ public function __construct( */ public function handle(CreateAttendeeDTO $attendeeDTO): AttendeeDomainObject { + $attendeeDTO = $this->resolveOccurrenceId($attendeeDTO); + + // Same eligibility checks the public checkout runs — manual creation + // previously bypassed every one of these, letting organisers issue + // tickets against cancelled or sold-out occurrences and ignore product + // visibility rules. The override_capacity flag opts out of the capacity + // check only and is audited below. + $this->occurrenceEligibilityService->assertOccurrencePurchasable( + eventId: $attendeeDTO->event_id, + occurrenceId: $attendeeDTO->event_occurrence_id, + additionalQuantity: 1, + overrideCapacity: $attendeeDTO->override_capacity, + ); + $this->occurrenceEligibilityService->assertProductsVisibleOnOccurrence( + $attendeeDTO->event_occurrence_id, + [$attendeeDTO->product_id], + ); + return $this->databaseManager->transaction(function () use ($attendeeDTO) { $this->calculateTaxesAndFees($attendeeDTO); @@ -75,7 +98,7 @@ public function handle(CreateAttendeeDTO $attendeeDTO): AttendeeDomainObject ProductDomainObjectAbstract::PRODUCT_TYPE => ProductType::TICKET->name, ]); - if (!$product) { + if (! $product) { throw new NoTicketsAvailableException(__('This ticket is invalid')); } @@ -87,25 +110,38 @@ public function handle(CreateAttendeeDTO $attendeeDTO): AttendeeDomainObject ); if ($availableQuantity <= 0) { - throw new NoTicketsAvailableException(__('There are no tickets available. ' . - 'If you would like to assign a product to this attendee,' . + throw new NoTicketsAvailableException(__('There are no tickets available. '. + 'If you would like to assign a product to this attendee,'. ' please adjust the product\'s available quantity.')); } - $productPriceId = $this->getProductPriceId($attendeeDTO, $product); - $this->processTaxesAndFees($attendeeDTO); $orderItem = $this->createOrderItem($attendeeDTO, $order, $product, $productPriceId); - $attendee = $this->createAttendee($order, $attendeeDTO); + // Use the resolved $productPriceId (not $attendeeDTO->product_price_id) + // so the attendee row and inventory adjustment match the order item. + // The DTO field is nullable — direct API callers can omit it and + // getProductPriceId() falls back to the product's first price. + $attendee = $this->createAttendee($order, $attendeeDTO, $productPriceId); $this->orderManagementService->updateOrderTotals($order, collect([$orderItem])); - $this->fireEventsAndUpdateQuantities($attendeeDTO, $order); + $this->fireEventsAndUpdateQuantities($attendeeDTO, $order, $productPriceId); $this->queueWebhooks($order); + if ($attendeeDTO->override_capacity) { + $this->orderAuditLogService->logManualAttendeeCapacityOverride( + eventId: $attendeeDTO->event_id, + orderId: $order->getId(), + attendeeId: $attendee->getId(), + occurrenceId: $attendeeDTO->event_occurrence_id, + ipAddress: $attendeeDTO->client_ip ?? '', + userAgent: $attendeeDTO->client_user_agent, + ); + } + return $attendee; }); } @@ -140,12 +176,13 @@ private function createOrder(int $eventId, CreateAttendeeDTO $attendeeDTO): Orde */ private function getProductPriceId(CreateAttendeeDTO $attendeeDTO, ProductDomainObject $product): int { - $priceIds = $product->getProductPrices()->map(fn(ProductPriceDomainObject $productPrice) => $productPrice->getId()); + $priceIds = $product->getProductPrices()->map(fn (ProductPriceDomainObject $productPrice) => $productPrice->getId()); if ($attendeeDTO->product_price_id) { - if (!$priceIds->contains($attendeeDTO->product_price_id)) { + if (! $priceIds->contains($attendeeDTO->product_price_id)) { throw new InvalidProductPriceId(__('The product price ID is invalid.')); } + return $attendeeDTO->product_price_id; } @@ -161,7 +198,7 @@ private function getProductPriceId(CreateAttendeeDTO $attendeeDTO, ProductDomain private function calculateTaxesAndFees(CreateAttendeeDTO $attendeeDTO): ?Collection { - if (!$attendeeDTO->taxes_and_fees) { + if (! $attendeeDTO->taxes_and_fees) { return null; } @@ -169,16 +206,18 @@ private function calculateTaxesAndFees(CreateAttendeeDTO $attendeeDTO): ?Collect 'id', $attendeeDTO ->taxes_and_fees - ->map(fn(CreateAttendeeTaxAndFeeDTO $taxAndFee) => $taxAndFee->tax_or_fee_id) + ->map(fn (CreateAttendeeTaxAndFeeDTO $taxAndFee) => $taxAndFee->tax_or_fee_id) ->toArray() ); $validatedTaxesAndFees = collect(); $attendeeDTO->taxes_and_fees->each(function (CreateAttendeeTaxAndFeeDTO $taxAndFee) use ($validatedTaxesAndFees, $taxesAndFees) { - $taxOrFee = $taxesAndFees->first(fn($taxOrFee) => $taxOrFee->getId() === $taxAndFee->tax_or_fee_id); + $taxOrFee = $taxesAndFees->first(fn ($taxOrFee) => $taxOrFee->getId() === $taxAndFee->tax_or_fee_id); - if (!$taxOrFee) { - throw new RuntimeException('Tax or fee not found.'); + if (! $taxOrFee) { + throw ValidationException::withMessages([ + 'taxes_and_fees' => __('One or more selected taxes or fees could not be found.'), + ]); } $validatedTaxesAndFees->push($taxOrFee); @@ -190,12 +229,12 @@ private function calculateTaxesAndFees(CreateAttendeeDTO $attendeeDTO): ?Collect private function processTaxesAndFees(CreateAttendeeDTO $attendeeDTO): void { $this->calculateTaxesAndFees($attendeeDTO) - ?->each(fn($taxOrFee) => $this->taxAndFeeRollupService + ?->each(fn ($taxOrFee) => $this->taxAndFeeRollupService ->addToRollUp( $taxOrFee, $attendeeDTO ->taxes_and_fees - ->first(fn($taxOrFeeDTO) => $taxOrFeeDTO->tax_or_fee_id === $taxOrFee->getId()) + ->first(fn ($taxOrFeeDTO) => $taxOrFeeDTO->tax_or_fee_id === $taxOrFee->getId()) ->amount) ); } @@ -215,16 +254,17 @@ private function createOrderItem(CreateAttendeeDTO $attendeeDTO, OrderDomainObje OrderItemDomainObjectAbstract::ITEM_NAME => $product->getTitle(), OrderItemDomainObjectAbstract::PRODUCT_PRICE_ID => $productPriceId, OrderItemDomainObjectAbstract::TAXES_AND_FEES_ROLLUP => $this->taxAndFeeRollupService->getRollUp(), + OrderItemDomainObjectAbstract::EVENT_OCCURRENCE_ID => $attendeeDTO->event_occurrence_id, ] ); } - private function createAttendee(OrderDomainObject $order, CreateAttendeeDTO $attendeeDTO): AttendeeDomainObject + private function createAttendee(OrderDomainObject $order, CreateAttendeeDTO $attendeeDTO, int $productPriceId): AttendeeDomainObject { return $this->attendeeRepository->create([ AttendeeDomainObjectAbstract::EVENT_ID => $order->getEventId(), AttendeeDomainObjectAbstract::PRODUCT_ID => $attendeeDTO->product_id, - AttendeeDomainObjectAbstract::PRODUCT_PRICE_ID => $attendeeDTO->product_price_id, + AttendeeDomainObjectAbstract::PRODUCT_PRICE_ID => $productPriceId, AttendeeDomainObjectAbstract::STATUS => AttendeeStatus::ACTIVE->name, AttendeeDomainObjectAbstract::EMAIL => $attendeeDTO->email, AttendeeDomainObjectAbstract::FIRST_NAME => $attendeeDTO->first_name, @@ -232,14 +272,16 @@ private function createAttendee(OrderDomainObject $order, CreateAttendeeDTO $att AttendeeDomainObjectAbstract::ORDER_ID => $order->getId(), AttendeeDomainObjectAbstract::PUBLIC_ID => IdHelper::publicId(IdHelper::ATTENDEE_PREFIX), AttendeeDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::ATTENDEE_PREFIX), + AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID => $attendeeDTO->event_occurrence_id, AttendeeDomainObjectAbstract::LOCALE => $attendeeDTO->locale, ]); } - private function fireEventsAndUpdateQuantities(CreateAttendeeDTO $attendeeDTO, OrderDomainObject $order): void + private function fireEventsAndUpdateQuantities(CreateAttendeeDTO $attendeeDTO, OrderDomainObject $order, int $productPriceId): void { $this->productQuantityAdjustmentService->increaseQuantitySold( - priceId: $attendeeDTO->product_price_id, + priceId: $productPriceId, + eventOccurrenceId: $attendeeDTO->event_occurrence_id, ); event(new OrderStatusChangedEvent( @@ -254,4 +296,34 @@ private function queueWebhooks(OrderDomainObject $order): void new OrderEvent(DomainEventType::ORDER_CREATED, $order->getId()) ); } + + private function resolveOccurrenceId(CreateAttendeeDTO $attendeeDTO): CreateAttendeeDTO + { + if ($attendeeDTO->event_occurrence_id !== null) { + return $attendeeDTO; + } + + $event = $this->eventRepository->findById($attendeeDTO->event_id); + + if ($event->getType() !== EventType::SINGLE->name) { + throw ValidationException::withMessages([ + 'event_occurrence_id' => __('An occurrence must be selected for recurring events.'), + ]); + } + + $occurrence = $this->eventOccurrenceRepository->findFirstWhere([ + 'event_id' => $attendeeDTO->event_id, + ]); + + if (!$occurrence) { + throw ValidationException::withMessages([ + 'event_occurrence_id' => __('No occurrence found for this event.'), + ]); + } + + return CreateAttendeeDTO::fromArray(array_merge( + $attendeeDTO->toArray(), + ['event_occurrence_id' => $occurrence->getId()] + )); + } } diff --git a/backend/app/Services/Application/Handlers/Attendee/DTO/CreateAttendeeDTO.php b/backend/app/Services/Application/Handlers/Attendee/DTO/CreateAttendeeDTO.php index 8cba021ad7..54e37bed4e 100644 --- a/backend/app/Services/Application/Handlers/Attendee/DTO/CreateAttendeeDTO.php +++ b/backend/app/Services/Application/Handlers/Attendee/DTO/CreateAttendeeDTO.php @@ -9,19 +9,21 @@ class CreateAttendeeDTO extends BaseDTO { public function __construct( - public readonly string $first_name, - public readonly string $last_name, - public readonly string $email, - public readonly int $product_id, - public readonly int $event_id, - public readonly bool $send_confirmation_email, - public readonly float $amount_paid, - public readonly string $locale, - public readonly ?bool $amount_includes_tax = false, - public readonly ?int $product_price_id = null, + public readonly string $first_name, + public readonly string $last_name, + public readonly string $email, + public readonly int $product_id, + public readonly int $event_id, + public readonly bool $send_confirmation_email, + public readonly float $amount_paid, + public readonly string $locale, + public readonly ?bool $amount_includes_tax = false, + public readonly ?int $product_price_id = null, + public readonly ?int $event_occurrence_id = null, + public readonly bool $override_capacity = false, + public readonly ?string $client_ip = null, + public readonly ?string $client_user_agent = null, #[CollectionOf(CreateAttendeeTaxAndFeeDTO::class)] public readonly ?Collection $taxes_and_fees = null, - ) - { - } + ) {} } diff --git a/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php b/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php index f7dd85f02a..79a8fac590 100644 --- a/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php +++ b/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php @@ -63,14 +63,15 @@ public function handle(EditAttendeeDTO $editAttendeeDTO): AttendeeDomainObject private function adjustProductQuantities(AttendeeDomainObject $attendee, EditAttendeeDTO $editAttendeeDTO): void { if ($attendee->getProductPriceId() !== $editAttendeeDTO->product_price_id) { - $this->productQuantityService->decreaseQuantitySold($attendee->getProductPriceId()); - $this->productQuantityService->increaseQuantitySold($editAttendeeDTO->product_price_id); + $this->productQuantityService->decreaseQuantitySold($attendee->getProductPriceId(), 1, $attendee->getEventOccurrenceId()); + $this->productQuantityService->increaseQuantitySold($editAttendeeDTO->product_price_id, 1, $attendee->getEventOccurrenceId()); event(new CapacityChangedEvent( eventId: $editAttendeeDTO->event_id, direction: CapacityChangeDirection::INCREASED, productId: $attendee->getProductId(), productPriceId: $attendee->getProductPriceId(), + eventOccurrenceId: $attendee->getEventOccurrenceId(), )); } } diff --git a/backend/app/Services/Application/Handlers/Attendee/GetAttendeesHandler.php b/backend/app/Services/Application/Handlers/Attendee/GetAttendeesHandler.php index d8e5881ebf..3cc09219da 100644 --- a/backend/app/Services/Application/Handlers/Attendee/GetAttendeesHandler.php +++ b/backend/app/Services/Application/Handlers/Attendee/GetAttendeesHandler.php @@ -3,6 +3,7 @@ namespace HiEvents\Services\Application\Handlers\Attendee; use HiEvents\DomainObjects\AttendeeCheckInDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\Http\DTO\QueryParamsDTO; use HiEvents\Repository\Eloquent\Value\Relationship; @@ -28,6 +29,10 @@ public function handle(int $eventId, QueryParamsDTO $queryParams): LengthAwarePa domainObject: AttendeeCheckInDomainObject::class, name: 'check_ins' )) + ->loadRelation(new Relationship( + domainObject: EventOccurrenceDomainObject::class, + name: 'event_occurrence' + )) ->findByEventId($eventId, $queryParams); } } diff --git a/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php b/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php index 36c5bc22a7..f0621859e1 100644 --- a/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php +++ b/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php @@ -93,22 +93,24 @@ private function updateAttendee(PartialEditAttendeeDTO $data): AttendeeDomainObj private function adjustProductQuantity(PartialEditAttendeeDTO $data, AttendeeDomainObject $attendee): void { if ($data->status === AttendeeStatus::ACTIVE->name) { - $this->productQuantityService->increaseQuantitySold($attendee->getProductPriceId()); + $this->productQuantityService->increaseQuantitySold($attendee->getProductPriceId(), 1, $attendee->getEventOccurrenceId()); event(new CapacityChangedEvent( eventId: $attendee->getEventId(), direction: CapacityChangeDirection::DECREASED, productId: $attendee->getProductId(), productPriceId: $attendee->getProductPriceId(), + eventOccurrenceId: $attendee->getEventOccurrenceId(), )); } elseif ($data->status === AttendeeStatus::CANCELLED->name) { - $this->productQuantityService->decreaseQuantitySold($attendee->getProductPriceId()); + $this->productQuantityService->decreaseQuantitySold($attendee->getProductPriceId(), 1, $attendee->getEventOccurrenceId()); event(new CapacityChangedEvent( eventId: $attendee->getEventId(), direction: CapacityChangeDirection::INCREASED, productId: $attendee->getProductId(), productPriceId: $attendee->getProductPriceId(), + eventOccurrenceId: $attendee->getEventOccurrenceId(), )); } } @@ -137,12 +139,14 @@ private function adjustEventStatistics(PartialEditAttendeeDTO $data, AttendeeDom if ($data->status === AttendeeStatus::CANCELLED->name) { $this->eventStatisticsCancellationService->decrementForCancelledAttendee( eventId: $attendee->getEventId(), - orderDate: $order->getCreatedAt() + orderDate: $order->getCreatedAt(), + occurrenceId: $attendee->getEventOccurrenceId(), ); } elseif ($data->status === AttendeeStatus::ACTIVE->name) { $this->eventStatisticsReactivationService->incrementForReactivatedAttendee( eventId: $attendee->getEventId(), - orderDate: $order->getCreatedAt() + orderDate: $order->getCreatedAt(), + occurrenceId: $attendee->getEventOccurrenceId(), ); } } diff --git a/backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php b/backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php index e8380b1766..5d5a7584e1 100644 --- a/backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php +++ b/backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php @@ -3,6 +3,7 @@ namespace HiEvents\Services\Application\Handlers\Attendee; use HiEvents\DomainObjects\EventSettingDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; @@ -36,6 +37,10 @@ public function handle(ResendAttendeeTicketDTO $resendAttendeeProductDTO): void ->loadRelation(new Relationship(OrderDomainObject::class, nested: [ new Relationship(OrderItemDomainObject::class), ], name: 'order')) + ->loadRelation(new Relationship( + domainObject: EventOccurrenceDomainObject::class, + name: 'event_occurrence', + )) ->findFirstWhere([ 'id' => $resendAttendeeProductDTO->attendeeId, 'event_id' => $resendAttendeeProductDTO->eventId, diff --git a/backend/app/Services/Application/Handlers/CheckInList/CreateCheckInListHandler.php b/backend/app/Services/Application/Handlers/CheckInList/CreateCheckInListHandler.php index 6682b2a346..d929ab44d9 100644 --- a/backend/app/Services/Application/Handlers/CheckInList/CreateCheckInListHandler.php +++ b/backend/app/Services/Application/Handlers/CheckInList/CreateCheckInListHandler.php @@ -25,7 +25,11 @@ public function handle(UpsertCheckInListDTO $listData): CheckInListDomainObject ->setDescription($listData->description) ->setEventId($listData->eventId) ->setExpiresAt($listData->expiresAt) - ->setActivatesAt($listData->activatesAt); + ->setActivatesAt($listData->activatesAt) + ->setEventOccurrenceId($listData->eventOccurrenceId) + ->setPublicShowAttendeeNotes($listData->publicShowAttendeeNotes) + ->setPublicShowQuestionAnswers($listData->publicShowQuestionAnswers) + ->setPublicShowOrderDetails($listData->publicShowOrderDetails); return $this->createCheckInListService->createCheckInList( checkInList: $checkInList, diff --git a/backend/app/Services/Application/Handlers/CheckInList/DTO/UpsertCheckInListDTO.php b/backend/app/Services/Application/Handlers/CheckInList/DTO/UpsertCheckInListDTO.php index 229a93c42f..5c58935dee 100644 --- a/backend/app/Services/Application/Handlers/CheckInList/DTO/UpsertCheckInListDTO.php +++ b/backend/app/Services/Application/Handlers/CheckInList/DTO/UpsertCheckInListDTO.php @@ -14,6 +14,10 @@ public function __construct( public ?string $expiresAt = null, public ?string $activatesAt = null, public ?int $id = null, + public ?int $eventOccurrenceId = null, + public bool $publicShowAttendeeNotes = true, + public bool $publicShowQuestionAnswers = true, + public bool $publicShowOrderDetails = true, ) { } diff --git a/backend/app/Services/Application/Handlers/CheckInList/DeleteCheckInListHandler.php b/backend/app/Services/Application/Handlers/CheckInList/DeleteCheckInListHandler.php index 1ef9674a57..0161c01ddd 100644 --- a/backend/app/Services/Application/Handlers/CheckInList/DeleteCheckInListHandler.php +++ b/backend/app/Services/Application/Handlers/CheckInList/DeleteCheckInListHandler.php @@ -2,6 +2,7 @@ namespace HiEvents\Services\Application\Handlers\CheckInList; +use HiEvents\Exceptions\ResourceConflictException; use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface; use Symfony\Component\Routing\Exception\ResourceNotFoundException; @@ -25,6 +26,12 @@ public function handle(int $eventId, int $checkInListId): void throw new ResourceNotFoundException(__('Check-in list not found')); } + if ($checkInList->getIsSystemDefault()) { + throw new ResourceConflictException( + __('The default check-in list can\'t be deleted.') + ); + } + $this->checkInListRepository->deleteWhere([ 'id' => $checkInListId, 'event_id' => $eventId, diff --git a/backend/app/Services/Application/Handlers/CheckInList/GetCheckInListsHandler.php b/backend/app/Services/Application/Handlers/CheckInList/GetCheckInListsHandler.php index 3725d05ff7..0789d4614e 100644 --- a/backend/app/Services/Application/Handlers/CheckInList/GetCheckInListsHandler.php +++ b/backend/app/Services/Application/Handlers/CheckInList/GetCheckInListsHandler.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\CheckInListDomainObject; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface; @@ -23,6 +24,7 @@ public function handle(GetCheckInListsDTO $dto): LengthAwarePaginator $checkInLists = $this->checkInListRepository ->loadRelation(ProductDomainObject::class) ->loadRelation(new Relationship(domainObject: EventDomainObject::class, name: 'event')) + ->loadRelation(new Relationship(domainObject: EventOccurrenceDomainObject::class, name: 'event_occurrence')) ->findByEventId( eventId: $dto->eventId, params: $dto->queryParams, diff --git a/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/PublicAttendeeDetailDTO.php b/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/PublicAttendeeDetailDTO.php new file mode 100644 index 0000000000..7ad997b4da --- /dev/null +++ b/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/PublicAttendeeDetailDTO.php @@ -0,0 +1,24 @@ + $currentListCheckIns + */ + public function __construct( + public AttendeeDomainObject $attendee, + public Collection $currentListCheckIns, + public bool $showNotes, + public bool $showQuestionAnswers, + public bool $showOrderDetails, + ) + { + } +} diff --git a/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeeDetailPublicHandler.php b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeeDetailPublicHandler.php new file mode 100644 index 0000000000..038c9e61f0 --- /dev/null +++ b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeeDetailPublicHandler.php @@ -0,0 +1,134 @@ +checkInListRepository + ->loadRelation(ProductDomainObject::class) + ->loadRelation(new Relationship(EventDomainObject::class, name: 'event')) + ->findFirstWhere([ + CheckInListDomainObjectAbstract::SHORT_ID => $shortId, + ]); + + if (!$checkInList) { + throw new ResourceNotFoundException(__('Check-in list not found')); + } + + $this->validateCheckInListIsActive($checkInList); + + $attendee = $this->attendeeRepository + ->loadRelation(new Relationship(OrderDomainObject::class, name: 'order')) + ->loadRelation(QuestionAndAnswerViewDomainObject::class) + ->loadRelation(new Relationship(ProductDomainObject::class, name: 'product')) + ->loadRelation(new Relationship(AttendeeCheckInDomainObject::class, name: 'check_ins')) + ->loadRelation(new Relationship(EventOccurrenceDomainObject::class, name: 'event_occurrence')) + ->findFirstWhere([ + 'public_id' => $attendeePublicId, + 'event_id' => $checkInList->getEventId(), + ]); + + if (!$attendee) { + throw new ResourceNotFoundException(__('Attendee not found')); + } + + $this->verifyAttendeeBelongsToCheckInList($checkInList, $attendee); + + $currentListCheckIns = $this->filterCheckInsForList($attendee->getCheckIns(), $checkInList->getId()); + $isStaff = $this->hasStaffAccess($checkInList, $staffAccountId); + + return new PublicAttendeeDetailDTO( + attendee: $attendee, + currentListCheckIns: $currentListCheckIns, + showNotes: $isStaff || $checkInList->getPublicShowAttendeeNotes(), + showQuestionAnswers: $isStaff || $checkInList->getPublicShowQuestionAnswers(), + showOrderDetails: $isStaff || $checkInList->getPublicShowOrderDetails(), + ); + } + + /** + * @return Collection + */ + private function filterCheckInsForList(?Collection $checkIns, int $checkInListId): Collection + { + if ($checkIns === null) { + return new Collection(); + } + + return $checkIns->filter( + static fn(AttendeeCheckInDomainObject $checkIn) => $checkIn->getCheckInListId() === $checkInListId + )->values(); + } + + private function hasStaffAccess(CheckInListDomainObject $checkInList, ?int $staffAccountId): bool + { + if ($staffAccountId === null) { + return false; + } + + $event = $checkInList->getEvent(); + if ($event === null) { + return false; + } + + return $event->getAccountId() === $staffAccountId; + } + + /** + * @throws CannotCheckInException + */ + private function validateCheckInListIsActive(CheckInListDomainObject $checkInList): void + { + if ($checkInList->getExpiresAt() && DateHelper::utcDateIsPast($checkInList->getExpiresAt())) { + throw new CannotCheckInException(__('Check-in list has expired')); + } + + if ($checkInList->getActivatesAt() && DateHelper::utcDateIsFuture($checkInList->getActivatesAt())) { + throw new CannotCheckInException(__('Check-in list is not active yet')); + } + } + + private function verifyAttendeeBelongsToCheckInList( + CheckInListDomainObject $checkInList, + AttendeeDomainObject $attendee, + ): void { + $allowedProductIds = $checkInList->getProducts()?->map(fn($product) => $product->getId())->toArray() ?? []; + + if (! empty($allowedProductIds) && ! in_array($attendee->getProductId(), $allowedProductIds, true)) { + throw new ResourceNotFoundException(__('Attendee not found')); + } + + if ($checkInList->getEventOccurrenceId() !== null + && $attendee->getEventOccurrenceId() !== $checkInList->getEventOccurrenceId() + ) { + throw new ResourceNotFoundException(__('Attendee not found')); + } + } +} diff --git a/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeePublicHandler.php b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeePublicHandler.php index 5879b08856..d599f099a2 100644 --- a/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeePublicHandler.php +++ b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeePublicHandler.php @@ -41,10 +41,18 @@ public function handle(string $shortId, string $attendeePublicId): AttendeeDomai $this->validateCheckInListIsActive($checkInList); - return $this->attendeeRepository->findFirstWhere([ + $attendee = $this->attendeeRepository->findFirstWhere([ 'public_id' => $attendeePublicId, 'event_id' => $checkInList->getEventId(), ]); + + if (! $attendee) { + throw new ResourceNotFoundException(__('Attendee not found')); + } + + $this->verifyAttendeeBelongsToCheckInList($checkInList, $attendee); + + return $attendee; } /** @@ -61,4 +69,21 @@ private function validateCheckInListIsActive(CheckInListDomainObject $checkInLis throw new CannotCheckInException(__('Check-in list is not active yet')); } } + + private function verifyAttendeeBelongsToCheckInList( + CheckInListDomainObject $checkInList, + AttendeeDomainObject $attendee, + ): void { + $allowedProductIds = $checkInList->getProducts()?->map(fn($product) => $product->getId())->toArray() ?? []; + + if (! empty($allowedProductIds) && ! in_array($attendee->getProductId(), $allowedProductIds, true)) { + throw new ResourceNotFoundException(__('Attendee not found')); + } + + if ($checkInList->getEventOccurrenceId() !== null + && $attendee->getEventOccurrenceId() !== $checkInList->getEventOccurrenceId() + ) { + throw new ResourceNotFoundException(__('Attendee not found')); + } + } } diff --git a/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandler.php b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandler.php index 8d25b1f036..93c14d5e21 100644 --- a/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandler.php +++ b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandler.php @@ -9,6 +9,7 @@ use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\Exceptions\CannotCheckInException; use HiEvents\Helper\DateHelper; +use HiEvents\Http\DTO\FilterFieldDTO; use HiEvents\Http\DTO\QueryParamsDTO; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; @@ -43,6 +44,8 @@ public function handle(string $shortId, QueryParamsDTO $queryParams): Paginator $this->validateCheckInListIsActive($checkInList); + $queryParams = $this->applyCheckInListOccurrenceScope($checkInList, $queryParams); + $attendees = $this->attendeeRepository->getAttendeesByCheckInShortId($shortId, $queryParams); // Set the check-in for each attendee @@ -54,6 +57,40 @@ public function handle(string $shortId, QueryParamsDTO $queryParams): Paginator return $attendees; } + /** + * Force the attendee query to the list's own occurrence when set; otherwise + * honour the client's filter (that's how the optional filter pill works). + */ + private function applyCheckInListOccurrenceScope( + CheckInListDomainObject $checkInList, + QueryParamsDTO $queryParams, + ): QueryParamsDTO { + $scopedOccurrenceId = $checkInList->getEventOccurrenceId(); + if ($scopedOccurrenceId === null) { + return $queryParams; + } + + $filterFields = ($queryParams->filter_fields ?? collect()) + ->reject(fn(FilterFieldDTO $f) => $f->field === 'event_occurrence_id') + ->push(new FilterFieldDTO( + field: 'event_occurrence_id', + operator: 'eq', + value: (string) $scopedOccurrenceId, + )) + ->values(); + + return new QueryParamsDTO( + page: $queryParams->page, + per_page: $queryParams->per_page, + sort_by: $queryParams->sort_by, + sort_direction: $queryParams->sort_direction, + query: $queryParams->query, + filter_fields: $filterFields, + includes: $queryParams->includes, + query_params: $queryParams->query_params, + ); + } + /** * @throws CannotCheckInException */ diff --git a/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListPublicHandler.php b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListPublicHandler.php index ffee8de664..b99154df17 100644 --- a/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListPublicHandler.php +++ b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListPublicHandler.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\CheckInListDomainObject; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\Repository\Eloquent\Value\Relationship; @@ -23,8 +24,12 @@ public function handle(string $shortId): CheckInListDomainObject $checkInList = $this->checkInListRepository ->loadRelation((new Relationship(domainObject: EventDomainObject::class, nested: [ new Relationship(domainObject: EventSettingDomainObject::class, name: 'event_settings'), + new Relationship(domainObject: EventOccurrenceDomainObject::class, name: 'event_occurrences'), ], name: 'event'))) ->loadRelation(ProductDomainObject::class) + // Load separately — it may be past and therefore absent from + // event.occurrences, which filters to future. + ->loadRelation(new Relationship(EventOccurrenceDomainObject::class, name: 'event_occurrence')) ->findFirstWhere([ 'short_id' => $shortId, ]); diff --git a/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListStatsPublicHandler.php b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListStatsPublicHandler.php new file mode 100644 index 0000000000..c80372e355 --- /dev/null +++ b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListStatsPublicHandler.php @@ -0,0 +1,44 @@ +checkInListRepository->findFirstWhere(['short_id' => $shortId]); + + if (!$checkInList) { + throw new ResourceNotFoundException(__('Check-in list not found')); + } + + // Scoped lists ignore the client filter (the list already owns an + // occurrence). Unscoped lists honour the filter pill. + $effectiveOverride = $checkInList->getEventOccurrenceId() !== null + ? null + : $clientOccurrenceFilter; + + $totals = $this->checkInListRepository->getCheckedInAttendeeCountById($checkInList->getId(), $effectiveOverride); + $perProduct = $this->checkInListRepository->getPerProductCheckInStatsById($checkInList->getId(), $effectiveOverride); + $recent = $this->checkInListRepository->getRecentCheckInsById($checkInList->getId(), self::RECENT_CHECK_INS_LIMIT, $effectiveOverride); + + return new CheckInListStatsDTO( + totalAttendees: $totals->totalAttendeesCount, + checkedInAttendees: $totals->checkedInCount, + perProduct: $perProduct->values()->all(), + recentCheckIns: $recent->values()->all(), + ); + } +} diff --git a/backend/app/Services/Application/Handlers/CheckInList/UpdateCheckInlistHandler.php b/backend/app/Services/Application/Handlers/CheckInList/UpdateCheckInlistHandler.php index d31d7873c3..439bc1c06a 100644 --- a/backend/app/Services/Application/Handlers/CheckInList/UpdateCheckInlistHandler.php +++ b/backend/app/Services/Application/Handlers/CheckInList/UpdateCheckInlistHandler.php @@ -26,7 +26,11 @@ public function handle(UpsertCheckInListDTO $data): CheckInListDomainObject ->setDescription($data->description) ->setEventId($data->eventId) ->setExpiresAt($data->expiresAt) - ->setActivatesAt($data->activatesAt); + ->setActivatesAt($data->activatesAt) + ->setEventOccurrenceId($data->eventOccurrenceId) + ->setPublicShowAttendeeNotes($data->publicShowAttendeeNotes) + ->setPublicShowQuestionAnswers($data->publicShowQuestionAnswers) + ->setPublicShowOrderDetails($data->publicShowOrderDetails); return $this->updateCheckInlistService->updateCheckInlist( checkInList: $checkInList, diff --git a/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php b/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php index 7b86a00011..b1a8c7d257 100644 --- a/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php +++ b/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php @@ -52,18 +52,21 @@ private function createEvent(CreateEventDTO $eventData): EventDomainObject ->setAccountId($eventData->account_id) ->setUserId($eventData->user_id) ->setTitle($eventData->title) - ->setStartDate($eventData->start_date) - ->setEndDate($eventData->end_date) ->setDescription($eventData->description) ->setAttributes($eventData->attributes?->toArray()) ->setTimezone($eventData->timezone ?? $organizer->getTimezone()) ->setCurrency($eventData->currency ?? $organizer->getCurrency()) ->setCategory($eventData->category?->value ?? EventCategory::OTHER->value) ->setStatus($eventData->status) + ->setType($eventData->type?->name) ->setEventSettings($eventData->event_settings) ->setLocationDetails($eventData->location_details?->toArray()); - $newEvent = $this->createEventService->createEvent($event); + $newEvent = $this->createEventService->createEvent( + eventData: $event, + startDate: $eventData->start_date, + endDate: $eventData->end_date, + ); $this->createProductCategoryService->createDefaultProductCategory($newEvent); diff --git a/backend/app/Services/Application/Handlers/Event/DTO/CreateEventDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/CreateEventDTO.php index e25ac39383..e0ea4b8aad 100644 --- a/backend/app/Services/Application/Handlers/Event/DTO/CreateEventDTO.php +++ b/backend/app/Services/Application/Handlers/Event/DTO/CreateEventDTO.php @@ -7,6 +7,7 @@ use HiEvents\DataTransferObjects\AttributesDTO; use HiEvents\DataTransferObjects\BaseDTO; use HiEvents\DomainObjects\Enums\EventCategory; +use HiEvents\DomainObjects\Enums\EventType; use HiEvents\DomainObjects\Status\EventStatus; use HiEvents\Services\Application\Handlers\EventSettings\DTO\UpdateEventSettingsDTO; use Illuminate\Support\Collection; @@ -29,6 +30,7 @@ public function __construct( public readonly ?EventCategory $category = null, public readonly ?AddressDTO $location_details = null, public readonly ?string $status = EventStatus::DRAFT->name, + public readonly ?EventType $type = EventType::SINGLE, public ?UpdateEventSettingsDTO $event_settings = null ) diff --git a/backend/app/Services/Application/Handlers/Event/DTO/EventStatsRequestDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/EventStatsRequestDTO.php index 9b21de9fa4..a308331df0 100644 --- a/backend/app/Services/Application/Handlers/Event/DTO/EventStatsRequestDTO.php +++ b/backend/app/Services/Application/Handlers/Event/DTO/EventStatsRequestDTO.php @@ -11,6 +11,7 @@ public function __construct( public ?string $start_date = null, public ?string $end_date = null, public string $date_range_preset = 'month', + public ?int $occurrence_id = null, ) { } diff --git a/backend/app/Services/Application/Handlers/Event/DTO/GetPublicEventDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/GetPublicEventDTO.php index c3d61ce009..cfbeb36f52 100644 --- a/backend/app/Services/Application/Handlers/Event/DTO/GetPublicEventDTO.php +++ b/backend/app/Services/Application/Handlers/Event/DTO/GetPublicEventDTO.php @@ -11,6 +11,7 @@ public function __construct( public bool $isAuthenticated, public ?string $ipAddress = null, public ?string $promoCode = null, + public ?int $eventOccurrenceId = null, ) { } diff --git a/backend/app/Services/Application/Handlers/Event/DuplicateEventHandler.php b/backend/app/Services/Application/Handlers/Event/DuplicateEventHandler.php index 11dd929687..e490c14acb 100644 --- a/backend/app/Services/Application/Handlers/Event/DuplicateEventHandler.php +++ b/backend/app/Services/Application/Handlers/Event/DuplicateEventHandler.php @@ -35,6 +35,7 @@ public function handle(DuplicateEventDataDTO $data): EventDomainObject duplicateTicketLogo: $data->duplicateTicketLogo, duplicateWebhooks: $data->duplicateWebhooks, duplicateAffiliates: $data->duplicateAffiliates, + duplicateOccurrences: $data->duplicateOccurrences, description: $data->description, endDate: $data->endDate, ); diff --git a/backend/app/Services/Application/Handlers/Event/GetEventsHandler.php b/backend/app/Services/Application/Handlers/Event/GetEventsHandler.php index d58beb6c43..10dba8d19d 100644 --- a/backend/app/Services/Application/Handlers/Event/GetEventsHandler.php +++ b/backend/app/Services/Application/Handlers/Event/GetEventsHandler.php @@ -2,6 +2,7 @@ namespace HiEvents\Services\Application\Handlers\Event; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\EventStatisticDomainObject; use HiEvents\DomainObjects\ImageDomainObject; @@ -24,6 +25,7 @@ public function __construct( public function handle(GetEventsDTO $dto): LengthAwarePaginator { return $this->eventRepository + ->loadRelation(new Relationship(EventOccurrenceDomainObject::class)) ->loadRelation(new Relationship(ImageDomainObject::class)) ->loadRelation(new Relationship(EventSettingDomainObject::class)) ->loadRelation(new Relationship(EventStatisticDomainObject::class)) diff --git a/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php b/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php index 12867e1912..28d66a741d 100644 --- a/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php +++ b/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php @@ -2,8 +2,11 @@ namespace HiEvents\Services\Application\Handlers\Event; +use HiEvents\DomainObjects\Enums\EventType; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; +use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract; use HiEvents\DomainObjects\Generated\PromoCodeDomainObjectAbstract; use HiEvents\DomainObjects\ImageDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; @@ -11,9 +14,11 @@ use HiEvents\DomainObjects\ProductCategoryDomainObject; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; +use HiEvents\DomainObjects\Status\EventOccurrenceStatus; use HiEvents\DomainObjects\TaxAndFeesDomainObject; use HiEvents\Repository\Eloquent\Value\OrderAndDirection; use HiEvents\Repository\Eloquent\Value\Relationship; +use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; use HiEvents\Services\Application\Handlers\Event\DTO\GetPublicEventDTO; @@ -22,14 +27,15 @@ class GetPublicEventHandler { + public const MAX_PUBLIC_OCCURRENCES = 200; + public function __construct( - private readonly EventRepositoryInterface $eventRepository, - private readonly PromoCodeRepositoryInterface $promoCodeRepository, - private readonly ProductFilterService $productFilterService, + private readonly EventRepositoryInterface $eventRepository, + private readonly EventOccurrenceRepositoryInterface $occurrenceRepository, + private readonly PromoCodeRepositoryInterface $promoCodeRepository, + private readonly ProductFilterService $productFilterService, private readonly EventPageViewIncrementService $eventPageViewIncrementService, - ) - { - } + ) {} public function handle(GetPublicEventDTO $data): EventDomainObject { @@ -55,22 +61,88 @@ public function handle(GetPublicEventDTO $data): EventDomainObject ], name: 'organizer')) ->findById($data->eventId); + $occurrenceWhere = [ + EventOccurrenceDomainObjectAbstract::EVENT_ID => $data->eventId, + [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name], + ]; + + if ($event->getType() === EventType::RECURRING->name) { + $occurrenceWhere[] = [EventOccurrenceDomainObjectAbstract::START_DATE, '>=', now()->toDateTimeString()]; + } + + // +1 lets us detect overflow without loading the entire occurrence table for + // long-running recurring events (e.g. daily over multiple years). + $occurrences = $this->occurrenceRepository->findWhere( + where: $occurrenceWhere, + orderAndDirections: [ + new OrderAndDirection(EventOccurrenceDomainObjectAbstract::START_DATE, 'asc'), + ], + limit: self::MAX_PUBLIC_OCCURRENCES + 1, + ); + + // Resolve once: only honour the requested occurrence id if it actually + // belongs to this event. The caller can supply any id, and downstream + // ProductFilterService applies visibility/capacity rules for whichever + // id we pass — so a cross-event id would otherwise leak another event's + // visibility-altered product payload through this event's response. + $verifiedOccurrence = null; + if ($data->eventOccurrenceId !== null) { + $verifiedOccurrence = $occurrences->first( + fn (EventOccurrenceDomainObject $o) => $o->getId() === $data->eventOccurrenceId + ); + if ($verifiedOccurrence === null) { + $verifiedOccurrence = $this->occurrenceRepository->findFirstWhere([ + EventOccurrenceDomainObjectAbstract::ID => $data->eventOccurrenceId, + EventOccurrenceDomainObjectAbstract::EVENT_ID => $data->eventId, + [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name], + ]); + } + // The fallback above only filters out CANCELLED — drop past dates + // here too. Without this, a stale share/email link to a past date + // resolves successfully, drives `productFilterService->filter` for + // that occurrence, and then the storefront date picker (which hides + // past dates) leaves the user with occurrence-filtered products and + // no selectable date. Treat past-link as "no occurrence verified" + // and let the payload fall back to the event-wide product set. + if ($verifiedOccurrence !== null && $verifiedOccurrence->isPast()) { + $verifiedOccurrence = null; + } + } + + $verifiedOccurrenceId = $verifiedOccurrence?->getId(); + + if ($occurrences->count() > self::MAX_PUBLIC_OCCURRENCES) { + $occurrences = $occurrences->take(self::MAX_PUBLIC_OCCURRENCES)->values(); + } + + // Append the verified occurrence when it isn't already in the public + // payload — covers two cases: (1) the linked occurrence was beyond the + // capped range for a long-running schedule, and (2) the requested id + // matched but only via the fallback ownership query (safety net). + if ($verifiedOccurrence !== null + && ! $occurrences->contains(fn (EventOccurrenceDomainObject $o) => $o->getId() === $verifiedOccurrenceId)) { + $occurrences->push($verifiedOccurrence); + } + + $event->setEventOccurrences($occurrences); + $promoCodeDomainObject = $this->promoCodeRepository->findFirstWhere([ PromoCodeDomainObjectAbstract::EVENT_ID => $data->eventId, PromoCodeDomainObjectAbstract::CODE => $data->promoCode, ]); - if (!$promoCodeDomainObject?->isValid()) { + if (! $promoCodeDomainObject?->isValid()) { $promoCodeDomainObject = null; } - if (!$data->isAuthenticated) { + if (! $data->isAuthenticated) { $this->eventPageViewIncrementService->increment($data->eventId, $data->ipAddress); } return $event->setProductCategories($this->productFilterService->filter( productsCategories: $event->getProductCategories(), - promoCode: $promoCodeDomainObject + promoCode: $promoCodeDomainObject, + eventOccurrenceId: $verifiedOccurrenceId, )); } } diff --git a/backend/app/Services/Application/Handlers/Event/GetPublicEventsHandler.php b/backend/app/Services/Application/Handlers/Event/GetPublicEventsHandler.php index 933f1fc1c0..af779c4b9c 100644 --- a/backend/app/Services/Application/Handlers/Event/GetPublicEventsHandler.php +++ b/backend/app/Services/Application/Handlers/Event/GetPublicEventsHandler.php @@ -2,6 +2,7 @@ namespace HiEvents\Services\Application\Handlers\Event; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\ImageDomainObject; use HiEvents\DomainObjects\ProductCategoryDomainObject; @@ -30,6 +31,7 @@ public function handle(GetPublicOrganizerEventsDTO $dto): LengthAwarePaginator $organizer = $this->organizerRepository->findById($dto->organizerId); $query = $this->eventRepository + ->loadRelation(new Relationship(EventOccurrenceDomainObject::class)) ->loadRelation( new Relationship(ProductCategoryDomainObject::class, [ new Relationship(ProductDomainObject::class, diff --git a/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php b/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php index 8f284ff631..9000b73c13 100644 --- a/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php +++ b/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php @@ -2,12 +2,14 @@ namespace HiEvents\Services\Application\Handlers\Event; +use HiEvents\DomainObjects\Enums\EventType; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\Status\OrderStatus; use HiEvents\Events\Dispatcher; use HiEvents\Events\EventUpdateEvent; use HiEvents\Exceptions\CannotChangeCurrencyException; use HiEvents\Helper\DateHelper; +use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Services\Application\Handlers\Event\DTO\UpdateEventDTO; @@ -21,11 +23,12 @@ readonly class UpdateEventHandler { public function __construct( - private EventRepositoryInterface $eventRepository, - private Dispatcher $dispatcher, - private DatabaseManager $databaseManager, - private OrderRepositoryInterface $orderRepository, - private HtmlPurifierService $purifier, + private EventRepositoryInterface $eventRepository, + private Dispatcher $dispatcher, + private DatabaseManager $databaseManager, + private OrderRepositoryInterface $orderRepository, + private HtmlPurifierService $purifier, + private EventOccurrenceRepositoryInterface $occurrenceRepository, ) { } @@ -73,10 +76,6 @@ private function updateEventAttributes(UpdateEventDTO $eventData): void attributes: [ 'title' => $eventData->title, 'category' => $eventData->category?->value ?? $existingEvent->getCategory(), - 'start_date' => DateHelper::convertToUTC($eventData->start_date, $eventData->timezone), - 'end_date' => $eventData->end_date - ? DateHelper::convertToUTC($eventData->end_date, $eventData->timezone) - : null, 'description' => $this->purifier->purify($eventData->description), 'timezone' => $eventData->timezone ?? $existingEvent->getTimezone(), 'currency' => $eventData->currency ?? $existingEvent->getCurrency(), @@ -88,6 +87,41 @@ private function updateEventAttributes(UpdateEventDTO $eventData): void 'account_id' => $eventData->account_id, ], ); + + $this->updateSingleOccurrenceDates($eventData, $existingEvent); + } + + private function updateSingleOccurrenceDates(UpdateEventDTO $eventData, EventDomainObject $existingEvent): void + { + if ($existingEvent->getType() !== EventType::SINGLE->name) { + return; + } + + if ($eventData->start_date === null) { + return; + } + + $timezone = $eventData->timezone ?? $existingEvent->getTimezone(); + + $occurrence = $this->occurrenceRepository->findFirstWhere([ + 'event_id' => $eventData->id, + ]); + + if ($occurrence === null) { + return; + } + + $this->occurrenceRepository->updateWhere( + attributes: [ + 'start_date' => DateHelper::convertToUTC($eventData->start_date, $timezone), + 'end_date' => $eventData->end_date + ? DateHelper::convertToUTC($eventData->end_date, $timezone) + : null, + ], + where: [ + 'id' => $occurrence->getId(), + ], + ); } private function getUpdateEvent(UpdateEventDTO $eventData): EventDomainObject diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandler.php new file mode 100644 index 0000000000..1ece2cffa4 --- /dev/null +++ b/backend/app/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandler.php @@ -0,0 +1,404 @@ +databaseManager->transaction(function () use ($dto) { + $occurrences = $this->occurrenceRepository->findWhere( + where: [ + EventOccurrenceDomainObjectAbstract::EVENT_ID => $dto->event_id, + ], + ); + + $eligible = $this->filterEligible($occurrences, $dto); + + return match ($dto->action) { + BulkOccurrenceAction::CANCEL => $this->handleCancel($dto, $eligible), + BulkOccurrenceAction::DELETE => $this->handleDelete($dto, $eligible), + BulkOccurrenceAction::UPDATE => $this->handleUpdate($dto, $eligible), + }; + }); + } + + private function filterEligible(Collection $occurrences, BulkUpdateOccurrencesDTO $dto): Collection + { + return $occurrences->filter(function (EventOccurrenceDomainObject $occurrence) use ($dto) { + if (! empty($dto->occurrence_ids) && ! in_array($occurrence->getId(), $dto->occurrence_ids, true)) { + return false; + } + + if ($dto->action !== BulkOccurrenceAction::DELETE && $occurrence->getStatus() === EventOccurrenceStatus::CANCELLED->name) { + return false; + } + + if ($dto->future_only && $occurrence->isPast()) { + return false; + } + + if ($dto->skip_overridden && $occurrence->getIsOverridden()) { + return false; + } + + return true; + }); + } + + private function handleCancel(BulkUpdateOccurrencesDTO $dto, Collection $eligible): BulkUpdateOccurrencesResultDTO + { + $ids = $this->collectIds($eligible); + + if (! empty($ids)) { + BulkCancelOccurrencesJob::dispatch($dto->event_id, $ids, $dto->refund_orders); + } + + return new BulkUpdateOccurrencesResultDTO( + updated_count: count($ids), + updated_ids: $ids, + ); + } + + private function handleDelete(BulkUpdateOccurrencesDTO $dto, Collection $eligible): BulkUpdateOccurrencesResultDTO + { + $eligibleIds = $this->collectIds($eligible); + + if (empty($eligibleIds)) { + return new BulkUpdateOccurrencesResultDTO(updated_count: 0, updated_ids: []); + } + + // Two batched lookups instead of 2N countWhere calls — bulk delete + // during regenerate can touch hundreds of occurrences. Mirrors the + // single-delete handler: attendees can exist without order items + // (imports, legacy data), so both checks must run. + $idsWithOrders = $this->orderItemRepository + ->findWhereIn( + field: OrderItemDomainObjectAbstract::EVENT_OCCURRENCE_ID, + values: $eligibleIds, + columns: [OrderItemDomainObjectAbstract::EVENT_OCCURRENCE_ID], + ) + ->map(fn (OrderItemDomainObject $item) => $item->getEventOccurrenceId()) + ->flip() + ->all(); + + $idsWithAttendees = $this->attendeeRepository + ->findWhereIn( + field: AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID, + values: $eligibleIds, + columns: [AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID], + ) + ->map(fn (AttendeeDomainObject $attendee) => $attendee->getEventOccurrenceId()) + ->flip() + ->all(); + + $deletableIds = []; + $deletableStartDates = []; + + foreach ($eligible as $occurrence) { + $id = $occurrence->getId(); + + if (! isset($idsWithOrders[$id]) && ! isset($idsWithAttendees[$id])) { + $deletableIds[] = $id; + $deletableStartDates[] = $occurrence->getStartDate(); + } + } + + if (! empty($deletableIds)) { + // FK is nullOnDelete; without this, WAITING/OFFERED entries scoped to + // the deleted occurrences become orphans and crash ProcessWaitlistService + // on the next CapacityChangedEvent. + $this->waitlistEntryRepository->updateWhere( + attributes: [ + 'status' => WaitlistEntryStatus::CANCELLED->name, + ], + where: [ + 'event_id' => $dto->event_id, + ['event_occurrence_id', 'in', $deletableIds], + ['status', 'in', [ + WaitlistEntryStatus::WAITING->name, + WaitlistEntryStatus::OFFERED->name, + ]], + ], + ); + + $this->occurrenceRepository->deleteWhere([ + [EventOccurrenceDomainObjectAbstract::ID, 'in', $deletableIds], + ]); + + $this->exclusionService->addExclusions($dto->event_id, $deletableStartDates); + } + + return new BulkUpdateOccurrencesResultDTO( + updated_count: count($deletableIds), + updated_ids: $deletableIds, + ); + } + + private function handleUpdate(BulkUpdateOccurrencesDTO $dto, Collection $eligible): BulkUpdateOccurrencesResultDTO + { + $requiresPerRow = $dto->start_time_shift !== null + || $dto->end_time_shift !== null + || $dto->duration_minutes !== null; + + if ($requiresPerRow) { + return $this->applyPerRowUpdate($dto, $eligible); + } + + return $this->applyUniformUpdate($dto, $eligible); + } + + private function applyUniformUpdate(BulkUpdateOccurrencesDTO $dto, Collection $eligible): BulkUpdateOccurrencesResultDTO + { + $attributes = $this->buildUniformAttributes($dto); + + if (empty($attributes)) { + return new BulkUpdateOccurrencesResultDTO(updated_count: 0, updated_ids: []); + } + + $capacityChanged = array_key_exists(EventOccurrenceDomainObjectAbstract::CAPACITY, $attributes); + + // Capacity changes diverge the occurrence from the rule's defaults — + // flag overridden so the next regenerate doesn't reset them. Label-only + // edits don't pin against regenerate (parity with single-edit handler). + if ($capacityChanged) { + $attributes[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] = true; + } + + $ids = $this->collectIds($eligible); + + if (empty($ids)) { + return new BulkUpdateOccurrencesResultDTO(updated_count: 0, updated_ids: []); + } + + $this->occurrenceRepository->updateWhere( + attributes: $attributes, + where: [ + [EventOccurrenceDomainObjectAbstract::ID, 'in', $ids], + ], + ); + + // Mirror the single-edit handler's status reconciliation: capacity is + // uniform across the batch but each occurrence carries its own + // used_capacity, so SOLD_OUT/ACTIVE has to flip per row. Two scoped + // updateWheres avoid an N-row loop while still respecting the uniform + // path. CANCELLED is left untouched on either side — its lifecycle + // belongs to the dedicated cancel/reactivate handlers. + if ($capacityChanged) { + $this->reconcileStatusForUniformCapacity( + ids: $ids, + newCapacity: $attributes[EventOccurrenceDomainObjectAbstract::CAPACITY], + ); + } + + return new BulkUpdateOccurrencesResultDTO( + updated_count: count($ids), + updated_ids: $ids, + ); + } + + /** + * @param int[] $ids + */ + private function reconcileStatusForUniformCapacity(array $ids, ?int $newCapacity): void + { + // Re-open any sold-out occurrence that now has headroom (or is now + // unlimited). + $reopenWhere = [ + [EventOccurrenceDomainObjectAbstract::ID, 'in', $ids], + [EventOccurrenceDomainObjectAbstract::STATUS, '=', EventOccurrenceStatus::SOLD_OUT->name], + ]; + if ($newCapacity !== null) { + $reopenWhere[] = [EventOccurrenceDomainObjectAbstract::USED_CAPACITY, '<', $newCapacity]; + } + + $this->occurrenceRepository->updateWhere( + attributes: [ + EventOccurrenceDomainObjectAbstract::STATUS => EventOccurrenceStatus::ACTIVE->name, + ], + where: $reopenWhere, + ); + + // Mark sold-out any active occurrence that the new ceiling has now + // exceeded. Only meaningful when the new capacity is finite. + if ($newCapacity !== null) { + $this->occurrenceRepository->updateWhere( + attributes: [ + EventOccurrenceDomainObjectAbstract::STATUS => EventOccurrenceStatus::SOLD_OUT->name, + ], + where: [ + [EventOccurrenceDomainObjectAbstract::ID, 'in', $ids], + [EventOccurrenceDomainObjectAbstract::STATUS, '=', EventOccurrenceStatus::ACTIVE->name], + [EventOccurrenceDomainObjectAbstract::USED_CAPACITY, '>=', $newCapacity], + ], + ); + } + } + + private function applyPerRowUpdate(BulkUpdateOccurrencesDTO $dto, Collection $eligible): BulkUpdateOccurrencesResultDTO + { + $updatedIds = []; + + foreach ($eligible as $occurrence) { + $attributes = $this->buildPerRowAttributes($dto, $occurrence); + + if (! empty($attributes)) { + // Same SOLD_OUT/ACTIVE reconciliation as the single-edit and + // uniform-bulk paths — when the bulk operation also tweaks + // capacity (e.g. shift_times + clear_capacity in one save) the + // status has to follow the new ceiling per row. + if (array_key_exists(EventOccurrenceDomainObjectAbstract::CAPACITY, $attributes)) { + $reconciled = $this->reconcileCapacityStatus( + currentStatus: $occurrence->getStatus(), + newCapacity: $attributes[EventOccurrenceDomainObjectAbstract::CAPACITY], + usedCapacity: $occurrence->getUsedCapacity() ?? 0, + ); + if ($reconciled !== null) { + $attributes[EventOccurrenceDomainObjectAbstract::STATUS] = $reconciled; + } + } + + $this->occurrenceRepository->updateWhere( + attributes: $attributes, + where: [EventOccurrenceDomainObjectAbstract::ID => $occurrence->getId()], + ); + $updatedIds[] = $occurrence->getId(); + } + } + + return new BulkUpdateOccurrencesResultDTO( + updated_count: count($updatedIds), + updated_ids: $updatedIds, + ); + } + + /** + * Returns the status the occurrence should sit at after a capacity edit, + * or null if the existing status is correct. Mirrors UpdateEventOccurrenceHandler + * — keep both in sync. Never touches CANCELLED. + */ + private function reconcileCapacityStatus( + string $currentStatus, + ?int $newCapacity, + int $usedCapacity, + ): ?string { + if ($currentStatus === EventOccurrenceStatus::CANCELLED->name) { + return null; + } + + if ($newCapacity === null) { + return $currentStatus === EventOccurrenceStatus::SOLD_OUT->name + ? EventOccurrenceStatus::ACTIVE->name + : null; + } + + $shouldBeSoldOut = $usedCapacity >= $newCapacity; + + if ($shouldBeSoldOut && $currentStatus !== EventOccurrenceStatus::SOLD_OUT->name) { + return EventOccurrenceStatus::SOLD_OUT->name; + } + + if (! $shouldBeSoldOut && $currentStatus === EventOccurrenceStatus::SOLD_OUT->name) { + return EventOccurrenceStatus::ACTIVE->name; + } + + return null; + } + + private function buildUniformAttributes(BulkUpdateOccurrencesDTO $dto): array + { + $attributes = []; + + if ($dto->clear_capacity) { + $attributes[EventOccurrenceDomainObjectAbstract::CAPACITY] = null; + } elseif ($dto->capacity !== null) { + $attributes[EventOccurrenceDomainObjectAbstract::CAPACITY] = $dto->capacity; + } + + if ($dto->clear_label) { + $attributes[EventOccurrenceDomainObjectAbstract::LABEL] = null; + } elseif ($dto->label !== null) { + $attributes[EventOccurrenceDomainObjectAbstract::LABEL] = $dto->label; + } + + return $attributes; + } + + private function buildPerRowAttributes(BulkUpdateOccurrencesDTO $dto, EventOccurrenceDomainObject $occurrence): array + { + $attributes = $this->buildUniformAttributes($dto); + $startEndChanged = false; + + if ($dto->start_time_shift !== null && $dto->start_time_shift !== 0) { + $start = Carbon::parse($occurrence->getStartDate(), 'UTC'); + $start->addMinutes($dto->start_time_shift); + $attributes[EventOccurrenceDomainObjectAbstract::START_DATE] = $start->toDateTimeString(); + $startEndChanged = true; + } + + if ($dto->end_time_shift !== null && $dto->end_time_shift !== 0 && $occurrence->getEndDate() !== null) { + $end = Carbon::parse($occurrence->getEndDate(), 'UTC'); + $end->addMinutes($dto->end_time_shift); + $attributes[EventOccurrenceDomainObjectAbstract::END_DATE] = $end->toDateTimeString(); + $startEndChanged = true; + } + + if ($dto->duration_minutes !== null) { + $startDate = $attributes[EventOccurrenceDomainObjectAbstract::START_DATE] ?? $occurrence->getStartDate(); + $start = Carbon::parse($startDate, 'UTC'); + $attributes[EventOccurrenceDomainObjectAbstract::END_DATE] = $start->copy()->addMinutes($dto->duration_minutes)->toDateTimeString(); + $startEndChanged = true; + } + + // Pin overridden so the next regenerate doesn't revert the shift. + // Capacity-only changes already flagged via buildUniformAttributes path. + if ($startEndChanged + || array_key_exists(EventOccurrenceDomainObjectAbstract::CAPACITY, $attributes) + ) { + $attributes[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] = true; + } + + return $attributes; + } + + private function collectIds(Collection $eligible): array + { + return $eligible->map(fn (EventOccurrenceDomainObject $o) => $o->getId())->values()->all(); + } +} diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/CancelOccurrenceHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/CancelOccurrenceHandler.php new file mode 100644 index 0000000000..d57214b5da --- /dev/null +++ b/backend/app/Services/Application/Handlers/EventOccurrence/CancelOccurrenceHandler.php @@ -0,0 +1,85 @@ +databaseManager->transaction(function () use ($eventId, $occurrenceId, &$wasCancelled) { + // Lock to prevent concurrent cancels from double-dispatching refund + // and notification side-effects below. + $occurrence = $this->occurrenceRepository->findByIdLocked($occurrenceId); + + if (! $occurrence || $occurrence->getEventId() !== $eventId) { + throw new ResourceNotFoundException( + __('Occurrence :id not found for event :eventId', [ + 'id' => $occurrenceId, + 'eventId' => $eventId, + ]) + ); + } + + if ($occurrence->getStatus() === EventOccurrenceStatus::CANCELLED->name) { + return $occurrence; + } + + $updated = $this->occurrenceRepository->updateFromArray( + id: $occurrenceId, + attributes: [ + EventOccurrenceDomainObjectAbstract::STATUS => EventOccurrenceStatus::CANCELLED->name, + ], + ); + + $this->cancelAttendeesService->cancelForOccurrence($eventId, $occurrenceId); + + $this->exclusionService->addExclusions($eventId, [$occurrence->getStartDate()]); + + $wasCancelled = true; + + return $updated; + }); + + if ($wasCancelled) { + event(new OccurrenceCancelledEvent( + eventId: $eventId, + occurrenceId: $occurrenceId, + refundOrders: $refundOrders, + )); + + event(new OccurrenceEvent( + type: DomainEventType::OCCURRENCE_CANCELLED, + occurrenceId: $occurrenceId, + )); + } + + return $updated; + } +} diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandler.php new file mode 100644 index 0000000000..186363c99a --- /dev/null +++ b/backend/app/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandler.php @@ -0,0 +1,42 @@ +databaseManager->transaction(function () use ($dto) { + return $this->occurrenceRepository->create([ + EventOccurrenceDomainObjectAbstract::EVENT_ID => $dto->event_id, + EventOccurrenceDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::OCCURRENCE_PREFIX), + EventOccurrenceDomainObjectAbstract::START_DATE => $dto->start_date, + EventOccurrenceDomainObjectAbstract::END_DATE => $dto->end_date, + EventOccurrenceDomainObjectAbstract::STATUS => EventOccurrenceStatus::ACTIVE->name, + EventOccurrenceDomainObjectAbstract::CAPACITY => $dto->capacity, + EventOccurrenceDomainObjectAbstract::USED_CAPACITY => 0, + EventOccurrenceDomainObjectAbstract::LABEL => $dto->label, + EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => $dto->is_overridden, + ]); + }); + } +} diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/DTO/BulkUpdateOccurrencesDTO.php b/backend/app/Services/Application/Handlers/EventOccurrence/DTO/BulkUpdateOccurrencesDTO.php new file mode 100644 index 0000000000..c16ef596fb --- /dev/null +++ b/backend/app/Services/Application/Handlers/EventOccurrence/DTO/BulkUpdateOccurrencesDTO.php @@ -0,0 +1,27 @@ +databaseManager->transaction(function () use ($eventId, $occurrenceId) { + $occurrence = $this->occurrenceRepository->findFirstWhere([ + EventOccurrenceDomainObjectAbstract::ID => $occurrenceId, + EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId, + ]); + + if (! $occurrence) { + throw new ResourceNotFoundException( + __('Occurrence :id not found for event :eventId', [ + 'id' => $occurrenceId, + 'eventId' => $eventId, + ]) + ); + } + + $orderCount = $this->orderItemRepository->countWhere([ + 'event_occurrence_id' => $occurrenceId, + ]); + + if ($orderCount > 0) { + throw ValidationException::withMessages([ + 'occurrence' => __('Cannot delete an occurrence that has orders. Cancel it instead.'), + ]); + } + + $attendeeCount = $this->attendeeRepository->countWhere([ + 'event_occurrence_id' => $occurrenceId, + ]); + + if ($attendeeCount > 0) { + throw ValidationException::withMessages([ + 'occurrence' => __('Cannot delete an occurrence that has attendees. Cancel it instead.'), + ]); + } + + $occurrenceStartDate = $occurrence->getStartDate(); + + // Cancel waitlist entries scoped to this occurrence BEFORE deleting + // it. The FK is `nullOnDelete`, so without this any WAITING/OFFERED + // entry would have its event_occurrence_id nulled — leaving an + // orphan that ProcessWaitlistService can't resolve, which throws + // ResourceConflictException and crashes the offer batch on the + // next CapacityChangedEvent. + $this->waitlistEntryRepository->updateWhere( + attributes: [ + 'status' => WaitlistEntryStatus::CANCELLED->name, + ], + where: [ + 'event_id' => $eventId, + 'event_occurrence_id' => $occurrenceId, + ['status', 'in', [ + WaitlistEntryStatus::WAITING->name, + WaitlistEntryStatus::OFFERED->name, + ]], + ], + ); + + $this->occurrenceRepository->deleteWhere([ + EventOccurrenceDomainObjectAbstract::ID => $occurrenceId, + ]); + + // For recurring events, the rule itself produces the candidate + // dates on the next regenerate. Without recording the deletion in + // excluded_occurrences, the regenerate parses the rule, sees the same + // candidate, and recreates the occurrence — silently undoing the + // delete. Mirror the cancel handler's behaviour here. + $this->appendOccurrenceToRecurrenceExclusions($eventId, $occurrenceStartDate); + }); + } + + private function appendOccurrenceToRecurrenceExclusions(int $eventId, string $startDate): void + { + $event = $this->eventRepository->findByIdLocked($eventId); + + if ($event === null || $event->getType() !== EventType::RECURRING->name) { + return; + } + + $recurrenceRule = $event->getRecurrenceRule() ?? []; + if (is_string($recurrenceRule)) { + $recurrenceRule = json_decode($recurrenceRule, true, 512, JSON_THROW_ON_ERROR); + } + + $excludedOccurrences = $recurrenceRule['excluded_occurrences'] ?? []; + $startDateTime = CarbonImmutable::parse($startDate, 'UTC') + ->setTimezone($event->getTimezone() ?? 'UTC') + ->format('Y-m-d H:i'); + + if (in_array($startDateTime, $excludedOccurrences, true)) { + return; + } + + $excludedOccurrences[] = $startDateTime; + $recurrenceRule['excluded_occurrences'] = $excludedOccurrences; + + $this->eventRepository->updateFromArray( + id: $eventId, + attributes: [ + EventDomainObjectAbstract::RECURRENCE_RULE => $recurrenceRule, + ], + ); + } +} diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/GenerateOccurrencesFromRuleHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/GenerateOccurrencesFromRuleHandler.php new file mode 100644 index 0000000000..c65f79c0bf --- /dev/null +++ b/backend/app/Services/Application/Handlers/EventOccurrence/GenerateOccurrencesFromRuleHandler.php @@ -0,0 +1,61 @@ +eventRepository->findById($dto->event_id); + $timezone = $event->getTimezone() ?? 'UTC'; + + $previewCount = $this->ruleParserService->parse($dto->recurrence_rule, $timezone)->count(); + + if ($previewCount > RecurrenceRuleParserService::MAX_OCCURRENCES) { + throw ValidationException::withMessages([ + 'recurrence_rule' => [ + __('This rule would generate too many occurrences. Please reduce the date range or frequency, or contact support.'), + ], + ]); + } + + return $this->databaseManager->transaction(function () use ($dto, $event) { + $this->eventRepository->updateFromArray( + id: $event->getId(), + attributes: [ + EventDomainObjectAbstract::RECURRENCE_RULE => $dto->recurrence_rule, + EventDomainObjectAbstract::TYPE => EventType::RECURRING->name, + ], + ); + + $event->setRecurrenceRule($dto->recurrence_rule); + + return $this->generatorService->generate($event, $dto->recurrence_rule); + }); + } +} diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandler.php new file mode 100644 index 0000000000..998c326bc4 --- /dev/null +++ b/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandler.php @@ -0,0 +1,41 @@ +occurrenceRepository + ->loadRelation(EventOccurrenceStatisticDomainObject::class) + ->findFirstWhere([ + EventOccurrenceDomainObjectAbstract::ID => $occurrenceId, + EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId, + ]); + + if (!$occurrence) { + throw new ResourceNotFoundException( + __('Occurrence :id not found for event :eventId', [ + 'id' => $occurrenceId, + 'eventId' => $eventId, + ]) + ); + } + + return $occurrence; + } +} diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandler.php new file mode 100644 index 0000000000..66f945a8c7 --- /dev/null +++ b/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandler.php @@ -0,0 +1,28 @@ +occurrenceRepository; + + if ($includeStats) { + $repository = $repository->loadRelation(EventOccurrenceStatisticDomainObject::class); + } + + return $repository->findByEventId($eventId, $queryParams); + } +} diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/GetProductVisibilityHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/GetProductVisibilityHandler.php new file mode 100644 index 0000000000..8a3020cc22 --- /dev/null +++ b/backend/app/Services/Application/Handlers/EventOccurrence/GetProductVisibilityHandler.php @@ -0,0 +1,40 @@ +occurrenceRepository->findFirstWhere([ + EventOccurrenceDomainObjectAbstract::ID => $occurrenceId, + EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId, + ]); + + if (!$occurrence) { + throw new ResourceNotFoundException( + __('Occurrence :id not found for this event', ['id' => $occurrenceId]) + ); + } + + return $this->visibilityRepository->findWhere([ + ProductOccurrenceVisibilityDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrenceId, + ]); + } +} diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/DTO/UpsertPriceOverrideDTO.php b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/DTO/UpsertPriceOverrideDTO.php new file mode 100644 index 0000000000..e2a18c80b5 --- /dev/null +++ b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/DTO/UpsertPriceOverrideDTO.php @@ -0,0 +1,17 @@ +databaseManager->transaction(function () use ($eventId, $occurrenceId, $overrideId) { + $occurrence = $this->occurrenceRepository->findFirstWhere([ + EventOccurrenceDomainObjectAbstract::ID => $occurrenceId, + EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId, + ]); + + if (!$occurrence) { + throw new ResourceNotFoundException( + __('Occurrence :id not found for event :eventId', [ + 'id' => $occurrenceId, + 'eventId' => $eventId, + ]) + ); + } + + $override = $this->overrideRepository->findFirstWhere([ + ProductPriceOccurrenceOverrideDomainObjectAbstract::ID => $overrideId, + ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrenceId, + ]); + + if (!$override) { + throw new ResourceNotFoundException( + __('Price override :id not found for occurrence :occurrenceId', [ + 'id' => $overrideId, + 'occurrenceId' => $occurrenceId, + ]) + ); + } + + $this->overrideRepository->deleteWhere([ + ProductPriceOccurrenceOverrideDomainObjectAbstract::ID => $overrideId, + ]); + }); + } +} diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/GetPriceOverridesHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/GetPriceOverridesHandler.php new file mode 100644 index 0000000000..3a63d9b187 --- /dev/null +++ b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/GetPriceOverridesHandler.php @@ -0,0 +1,40 @@ +occurrenceRepository->findFirstWhere([ + EventOccurrenceDomainObjectAbstract::ID => $occurrenceId, + EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId, + ]); + + if (!$occurrence) { + throw new ResourceNotFoundException( + __('Occurrence :id not found for this event', ['id' => $occurrenceId]) + ); + } + + return $this->overrideRepository->findWhere([ + ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrenceId, + ]); + } +} diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandler.php new file mode 100644 index 0000000000..7bdee33228 --- /dev/null +++ b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandler.php @@ -0,0 +1,87 @@ +occurrenceRepository->findFirstWhere([ + EventOccurrenceDomainObjectAbstract::ID => $dto->event_occurrence_id, + EventOccurrenceDomainObjectAbstract::EVENT_ID => $dto->event_id, + ]); + + if (!$occurrence) { + throw new ResourceNotFoundException( + __('Occurrence :id not found for this event', ['id' => $dto->event_occurrence_id]) + ); + } + + $productPrice = $this->productPriceRepository->findFirst($dto->product_price_id); + if (!$productPrice) { + throw new ResourceNotFoundException( + __('Product price :id not found', ['id' => $dto->product_price_id]) + ); + } + + $product = $this->productRepository->findFirstWhere([ + 'id' => $productPrice->getProductId(), + 'event_id' => $dto->event_id, + ]); + + if (!$product) { + throw new ResourceNotFoundException( + __('Product price :id does not belong to this event', ['id' => $dto->product_price_id]) + ); + } + + return $this->databaseManager->transaction(function () use ($dto) { + $existing = $this->overrideRepository->findFirstWhere([ + ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => $dto->event_occurrence_id, + ProductPriceOccurrenceOverrideDomainObjectAbstract::PRODUCT_PRICE_ID => $dto->product_price_id, + ]); + + if ($existing) { + return $this->overrideRepository->updateFromArray( + id: $existing->getId(), + attributes: [ + ProductPriceOccurrenceOverrideDomainObjectAbstract::PRICE => $dto->price, + ], + ); + } + + return $this->overrideRepository->create([ + ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => $dto->event_occurrence_id, + ProductPriceOccurrenceOverrideDomainObjectAbstract::PRODUCT_PRICE_ID => $dto->product_price_id, + ProductPriceOccurrenceOverrideDomainObjectAbstract::PRICE => $dto->price, + ]); + }); + } +} diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/ReactivateOccurrenceHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/ReactivateOccurrenceHandler.php new file mode 100644 index 0000000000..23f184797f --- /dev/null +++ b/backend/app/Services/Application/Handlers/EventOccurrence/ReactivateOccurrenceHandler.php @@ -0,0 +1,60 @@ +databaseManager->transaction(function () use ($eventId, $occurrenceId) { + $occurrence = $this->occurrenceRepository->findByIdLocked($occurrenceId); + + if (! $occurrence || $occurrence->getEventId() !== $eventId) { + throw new ResourceNotFoundException( + __('Occurrence :id not found for event :eventId', [ + 'id' => $occurrenceId, + 'eventId' => $eventId, + ]) + ); + } + + if ($occurrence->getStatus() !== EventOccurrenceStatus::CANCELLED->name) { + throw ValidationException::withMessages([ + 'status' => __('Only cancelled dates can be reactivated.'), + ]); + } + + $updated = $this->occurrenceRepository->updateFromArray( + id: $occurrenceId, + attributes: [ + EventOccurrenceDomainObjectAbstract::STATUS => EventOccurrenceStatus::ACTIVE->name, + ], + ); + + $this->exclusionService->removeExclusion($eventId, $occurrence->getStartDate()); + + return $updated; + }); + } +} diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandler.php new file mode 100644 index 0000000000..13780dcc39 --- /dev/null +++ b/backend/app/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandler.php @@ -0,0 +1,134 @@ +databaseManager->transaction(function () use ($occurrenceId, $dto) { + $occurrence = $this->occurrenceRepository->findFirstWhere([ + EventOccurrenceDomainObjectAbstract::ID => $occurrenceId, + EventOccurrenceDomainObjectAbstract::EVENT_ID => $dto->event_id, + ]); + + if (! $occurrence) { + throw new ResourceNotFoundException( + __('Occurrence :id not found for event :eventId', [ + 'id' => $occurrenceId, + 'eventId' => $dto->event_id, + ]) + ); + } + + // Only flag as overridden when a rule-determining field actually changes + // away from what the rule would produce. Label edits and no-op saves + // shouldn't pin the row against rule regenerates. Dates are normalized + // to a canonical parsed form — `DateHelper::convertToUTC` returns a + // different string format than the DB-hydrated getStartDate(), so + // string comparison alone would always register as changed. + $isOverride = $occurrence->getIsOverridden() + || $this->datesDiffer($dto->start_date, $occurrence->getStartDate()) + || $this->datesDiffer($dto->end_date, $occurrence->getEndDate()) + || $dto->capacity !== $occurrence->getCapacity(); + + // CANCELLED is intentionally not writable here — lifecycle + // transitions (cancel / reactivate) live in their own handlers + // and fan out into refund / attendee / recurrence-exclusion side + // effects this handler does not perform. SOLD_OUT/ACTIVE on the + // other hand are capacity-derived: ProductQuantityUpdateService + // flips between them whenever used_capacity crosses capacity, so + // a capacity edit here has to do the same reconciliation or a + // sold-out date stays blocked after capacity is increased / cleared, + // and an over-capacity active date stays visually open. + $attributes = [ + EventOccurrenceDomainObjectAbstract::START_DATE => $dto->start_date, + EventOccurrenceDomainObjectAbstract::END_DATE => $dto->end_date, + EventOccurrenceDomainObjectAbstract::CAPACITY => $dto->capacity, + EventOccurrenceDomainObjectAbstract::LABEL => $dto->label, + EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => $isOverride, + ]; + + $reconciledStatus = $this->reconcileCapacityStatus( + currentStatus: $occurrence->getStatus(), + newCapacity: $dto->capacity, + usedCapacity: $occurrence->getUsedCapacity() ?? 0, + ); + if ($reconciledStatus !== null) { + $attributes[EventOccurrenceDomainObjectAbstract::STATUS] = $reconciledStatus; + } + + return $this->occurrenceRepository->updateFromArray( + id: $occurrence->getId(), + attributes: $attributes, + ); + }); + } + + /** + * Returns the status the occurrence should sit at after a capacity edit, + * or null if the existing status is correct. Only flips between ACTIVE + * and SOLD_OUT — never touches CANCELLED, which is owned by the dedicated + * cancel/reactivate handlers. + */ + private function reconcileCapacityStatus( + string $currentStatus, + ?int $newCapacity, + int $usedCapacity, + ): ?string { + if ($currentStatus === EventOccurrenceStatus::CANCELLED->name) { + return null; + } + + // Unlimited (null) capacity can never be sold out. + if ($newCapacity === null) { + return $currentStatus === EventOccurrenceStatus::SOLD_OUT->name + ? EventOccurrenceStatus::ACTIVE->name + : null; + } + + $shouldBeSoldOut = $usedCapacity >= $newCapacity; + + if ($shouldBeSoldOut && $currentStatus !== EventOccurrenceStatus::SOLD_OUT->name) { + return EventOccurrenceStatus::SOLD_OUT->name; + } + + if (! $shouldBeSoldOut && $currentStatus === EventOccurrenceStatus::SOLD_OUT->name) { + return EventOccurrenceStatus::ACTIVE->name; + } + + return null; + } + + private function datesDiffer(?string $a, ?string $b): bool + { + if ($a === null && $b === null) { + return false; + } + if ($a === null || $b === null) { + return true; + } + + return ! Carbon::parse($a, 'UTC')->equalTo(Carbon::parse($b, 'UTC')); + } +} diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandler.php new file mode 100644 index 0000000000..84134e228a --- /dev/null +++ b/backend/app/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandler.php @@ -0,0 +1,85 @@ +occurrenceRepository->findFirstWhere([ + EventOccurrenceDomainObjectAbstract::ID => $dto->event_occurrence_id, + EventOccurrenceDomainObjectAbstract::EVENT_ID => $dto->event_id, + ]); + + if (!$occurrence) { + throw new ResourceNotFoundException( + __('Occurrence :id not found for this event', ['id' => $dto->event_occurrence_id]) + ); + } + + return $this->databaseManager->transaction(function () use ($dto) { + $this->visibilityRepository->deleteWhere([ + ProductOccurrenceVisibilityDomainObjectAbstract::EVENT_OCCURRENCE_ID => $dto->event_occurrence_id, + ]); + + $allProducts = $this->productRepository->findWhere([ + ProductDomainObjectAbstract::EVENT_ID => $dto->event_id, + ]); + + $allProductIds = $allProducts->pluck('id')->sort()->values()->toArray(); + $selectedProductIds = collect($dto->product_ids)->sort()->values()->toArray(); + + $invalidIds = array_diff($selectedProductIds, $allProductIds); + if (!empty($invalidIds)) { + throw new ResourceNotFoundException( + __('One or more product IDs do not belong to this event') + ); + } + + if ($allProductIds === $selectedProductIds) { + return collect(); + } + + // The request rule enforces `distinct`, but dedupe defensively in + // case a future caller bypasses request validation — the unique + // constraint on (event_occurrence_id, product_id) would otherwise + // surface as a 500 mid-batch. + foreach (array_unique($dto->product_ids) as $productId) { + $this->visibilityRepository->create([ + ProductOccurrenceVisibilityDomainObjectAbstract::EVENT_OCCURRENCE_ID => $dto->event_occurrence_id, + ProductOccurrenceVisibilityDomainObjectAbstract::PRODUCT_ID => $productId, + ]); + } + + return $this->visibilityRepository->findWhere([ + ProductOccurrenceVisibilityDomainObjectAbstract::EVENT_OCCURRENCE_ID => $dto->event_occurrence_id, + ]); + }); + } +} diff --git a/backend/app/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandler.php b/backend/app/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandler.php index d741e3e764..2b9e301e9f 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandler.php @@ -3,9 +3,10 @@ namespace HiEvents\Services\Application\Handlers\EventSettings; use Brick\Money\Currency; -use HiEvents\DomainObjects\AccountConfigurationDomainObject; +use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\OrganizerConfigurationDomainObject; +use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Repository\Eloquent\Value\Relationship; -use HiEvents\Repository\Interfaces\AccountRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Services\Application\Handlers\EventSettings\DTO\GetPlatformFeePreviewDTO; use HiEvents\Services\Application\Handlers\EventSettings\DTO\PlatformFeePreviewResponseDTO; @@ -14,7 +15,6 @@ class GetPlatformFeePreviewHandler { public function __construct( - private readonly AccountRepositoryInterface $accountRepository, private readonly EventRepositoryInterface $eventRepository, private readonly CurrencyConversionClientInterface $currencyConversionClient, ) @@ -23,17 +23,22 @@ public function __construct( public function handle(GetPlatformFeePreviewDTO $dto): PlatformFeePreviewResponseDTO { - $event = $this->eventRepository->findById($dto->eventId); - $eventCurrency = $event->getCurrency(); - - $account = $this->accountRepository + /** @var EventDomainObject $event */ + $event = $this->eventRepository ->loadRelation(new Relationship( - domainObject: AccountConfigurationDomainObject::class, - name: 'configuration', + domainObject: OrganizerDomainObject::class, + nested: [ + new Relationship( + domainObject: OrganizerConfigurationDomainObject::class, + name: 'organizer_configuration', + ), + ], + name: 'organizer', )) - ->findByEventId($dto->eventId); + ->findById($dto->eventId); - $configuration = $account->getConfiguration(); + $eventCurrency = $event->getCurrency(); + $configuration = $event->getOrganizer()?->getOrganizerConfiguration(); if ($configuration === null) { return new PlatformFeePreviewResponseDTO( diff --git a/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php b/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php index ef6eb83ce4..37d10e8d11 100644 --- a/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php +++ b/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php @@ -8,21 +8,26 @@ class SendMessageDTO extends BaseDTO { public function __construct( - public readonly int $account_id, - public readonly int $event_id, - public readonly string $subject, - public readonly string $message, + public readonly int $account_id, + public readonly int $event_id, + public readonly string $subject, + public readonly string $message, public readonly MessageTypeEnum $type, - public readonly bool $is_test, - public readonly bool $send_copy_to_current_user, - public readonly int $sent_by_user_id, - public readonly ?int $order_id = null, - public readonly ?array $order_statuses = [], - public readonly ?int $id = null, - public readonly ?array $attendee_ids = [], - public readonly ?array $product_ids = [], - public readonly ?string $scheduled_at = null, - ) - { - } + public readonly bool $is_test, + public readonly bool $send_copy_to_current_user, + public readonly int $sent_by_user_id, + public readonly ?int $order_id = null, + public readonly ?array $order_statuses = [], + public readonly ?int $id = null, + public readonly ?array $attendee_ids = [], + public readonly ?array $product_ids = [], + public readonly ?string $scheduled_at = null, + public readonly ?int $event_occurrence_id = null, + /** + * When set, filters recipients to attendees/orders tied to any of these + * occurrences. Mutually exclusive with event_occurrence_id (handler + * prefers this when both are provided). + */ + public readonly ?array $event_occurrence_ids = null, + ) {} } diff --git a/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php b/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php index 9f1b7b9872..57b7b639b7 100644 --- a/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php +++ b/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php @@ -27,18 +27,16 @@ class SendMessageHandler { public function __construct( - private readonly OrderRepositoryInterface $orderRepository, - private readonly AttendeeRepositoryInterface $attendeeRepository, - private readonly ProductRepositoryInterface $productRepository, - private readonly MessageRepositoryInterface $messageRepository, - private readonly AccountRepositoryInterface $accountRepository, - private readonly EventRepositoryInterface $eventRepository, - private readonly HtmlPurifierService $purifier, - private readonly Repository $config, - private readonly MessagingEligibilityService $eligibilityService, - ) - { - } + private readonly OrderRepositoryInterface $orderRepository, + private readonly AttendeeRepositoryInterface $attendeeRepository, + private readonly ProductRepositoryInterface $productRepository, + private readonly MessageRepositoryInterface $messageRepository, + private readonly AccountRepositoryInterface $accountRepository, + private readonly EventRepositoryInterface $eventRepository, + private readonly HtmlPurifierService $purifier, + private readonly Repository $config, + private readonly MessagingEligibilityService $eligibilityService, + ) {} /** * @throws AccountNotVerifiedException @@ -52,12 +50,12 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject throw new AccountNotVerifiedException(__('You cannot send messages until your account is verified.')); } - if ($this->config->get('app.saas_mode_enabled') && !$account->getIsManuallyVerified()) { + if ($this->config->get('app.saas_mode_enabled') && ! $account->getIsManuallyVerified()) { throw new AccountNotVerifiedException( - __('Due to issues with spam, you must contact us to enable your account for sending messages. ' . + __('Due to issues with spam, you must contact us to enable your account for sending messages. '. 'Please contact us at :email', [ - 'email' => $this->config->get('app.platform_support_email'), - ]) + 'email' => $this->config->get('app.platform_support_email'), + ]) ); } @@ -77,7 +75,7 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject $messageData->event_id ); - $isScheduled = $messageData->scheduled_at !== null && !$messageData->is_test; + $isScheduled = $messageData->scheduled_at !== null && ! $messageData->is_test; $event = $this->eventRepository->findById($messageData->event_id); @@ -105,6 +103,7 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject 'message' => $this->purifier->purify($messageData->message), 'type' => $messageData->type->name, 'order_id' => $this->getOrderId($messageData), + 'event_occurrence_id' => $messageData->event_occurrence_id, 'attendee_ids' => $this->getAttendeeIds($messageData)->toArray(), 'product_ids' => $this->getProductIds($messageData)->toArray(), 'sent_at' => $isScheduled ? null : Carbon::now()->toDateTimeString(), @@ -119,6 +118,10 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject 'account_id' => $messageData->account_id, 'attendee_ids' => $messageData->attendee_ids, 'product_ids' => $messageData->product_ids, + // event_occurrence_ids doesn't have a dedicated column — messages + // only have a single event_occurrence_id FK — so we persist the + // array here for audit + job replay. + 'event_occurrence_ids' => $messageData->event_occurrence_ids, ], ]); @@ -139,6 +142,8 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject 'id' => $message->getId(), 'attendee_ids' => $message->getAttendeeIds(), 'product_ids' => $message->getProductIds(), + 'event_occurrence_id' => $messageData->event_occurrence_id, + 'event_occurrence_ids' => $messageData->event_occurrence_ids, ]); SendMessagesJob::dispatch($updatedData); @@ -149,24 +154,45 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject private function estimateRecipientCount(SendMessageDTO $messageData): int { + $occurrenceCondition = $this->occurrenceWhere($messageData); + return match ($messageData->type) { MessageTypeEnum::INDIVIDUAL_ATTENDEES => count($messageData->attendee_ids ?? []), MessageTypeEnum::ORDER_OWNER => 1, - MessageTypeEnum::ALL_ATTENDEES => $this->attendeeRepository->countWhere([ + MessageTypeEnum::ALL_ATTENDEES => $this->attendeeRepository->countWhere(array_merge([ 'event_id' => $messageData->event_id, - ]), - MessageTypeEnum::TICKET_HOLDERS => $this->attendeeRepository->countWhere([ + ], $occurrenceCondition)), + MessageTypeEnum::TICKET_HOLDERS => $this->attendeeRepository->countWhere(array_merge([ 'event_id' => $messageData->event_id, ['product_id', 'in', $messageData->product_ids ?? []], - ]), + ], $occurrenceCondition)), MessageTypeEnum::ORDER_OWNERS_WITH_PRODUCT => $this->orderRepository->countOrdersAssociatedWithProducts( eventId: $messageData->event_id, productIds: $messageData->product_ids ?? [], orderStatuses: $messageData->order_statuses ?? ['COMPLETED'], + eventOccurrenceId: $messageData->event_occurrence_id, + eventOccurrenceIds: $messageData->event_occurrence_ids, ), }; } + /** + * Build the `where` fragment that scopes a recipient query to the target + * occurrences. Prefers event_occurrence_ids (multi) over event_occurrence_id + * (single); returns an empty array when neither is set (= whole event). + */ + private function occurrenceWhere(SendMessageDTO $messageData): array + { + if (! empty($messageData->event_occurrence_ids)) { + return [['event_occurrence_id', 'in', $messageData->event_occurrence_ids]]; + } + if ($messageData->event_occurrence_id) { + return ['event_occurrence_id' => $messageData->event_occurrence_id]; + } + + return []; + } + private function getAttendeeIds(SendMessageDTO $messageData): Collection { $attendees = $this->attendeeRepository->findWhereIn( @@ -178,10 +204,9 @@ private function getAttendeeIds(SendMessageDTO $messageData): Collection columns: ['id'] ); - return $attendees->map(fn($attendee) => $attendee->getId()); + return $attendees->map(fn ($attendee) => $attendee->getId()); } - private function getProductIds(SendMessageDTO $messageData): Collection { $products = $this->productRepository->findWhereIn( @@ -193,7 +218,7 @@ private function getProductIds(SendMessageDTO $messageData): Collection columns: ['id'] ); - return $products->map(fn($product) => $product->getId()); + return $products->map(fn ($product) => $product->getId()); } private function getOrderId(SendMessageDTO $messageData): ?int diff --git a/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php b/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php index 49abae7e49..6bce172691 100644 --- a/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php +++ b/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php @@ -27,6 +27,7 @@ use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\AffiliateRepositoryInterface; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface; @@ -62,6 +63,7 @@ public function __construct( private readonly DomainEventDispatcherService $domainEventDispatcherService, private readonly EventSettingsRepositoryInterface $eventSettingsRepository, private readonly CheckoutSessionManagementService $sessionManagementService, + private readonly EventOccurrenceRepositoryInterface $occurrenceRepository, ) { } @@ -81,6 +83,8 @@ public function handle(string $orderShortId, CompleteOrderDTO $orderData): Order $order = $this->getOrder($orderShortId); + $this->validateOccurrenceStatus($order); + $updatedOrder = $this->updateOrder($order, $orderDTO); $this->createAttendees($orderData->products, $order, $orderDTO, $eventSettings); @@ -144,13 +148,15 @@ private function createAttendees( $isPerOrderCollection = $eventSettings->getAttendeeDetailsCollectionMethod() === AttendeeDetailsCollectionMethod::PER_ORDER->name; $this->validateTicketProductsCount($order, $orderProducts); + $orderItemRemainingQuantities = $order->getOrderItems() + ->mapWithKeys(fn(OrderItemDomainObject $item) => [$item->getId() => $item->getQuantity()]); + foreach ($orderProducts as $attendee) { $productId = $productsPrices->first( fn(ProductPriceDomainObject $productPrice) => $productPrice->getId() === $attendee->product_price_id) ->getProductId(); $productType = $this->getProductTypeFromPriceId($attendee->product_price_id, $order->getOrderItems()); - // If it's not a ticket, skip, as we only want to create attendees for tickets if ($productType !== ProductType::TICKET->name) { $createdProductData->push(new CreatedProductDataDTO( productRequestData: $attendee, @@ -160,12 +166,22 @@ private function createAttendees( continue; } + $orderItem = $order->getOrderItems()->first( + fn(OrderItemDomainObject $item) => $item->getProductPriceId() === $attendee->product_price_id + && ($orderItemRemainingQuantities[$item->getId()] ?? 0) > 0 + ); + + if ($orderItem !== null) { + $orderItemRemainingQuantities[$orderItem->getId()] = $orderItemRemainingQuantities[$orderItem->getId()] - 1; + } + $shortId = IdHelper::shortId(IdHelper::ATTENDEE_PREFIX); $inserts[] = [ AttendeeDomainObjectAbstract::EVENT_ID => $order->getEventId(), AttendeeDomainObjectAbstract::PRODUCT_ID => $productId, AttendeeDomainObjectAbstract::PRODUCT_PRICE_ID => $attendee->product_price_id, + AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID => $orderItem?->getEventOccurrenceId(), AttendeeDomainObjectAbstract::STATUS => $order->isPaymentRequired() ? AttendeeStatus::AWAITING_PAYMENT->name : AttendeeStatus::ACTIVE->name, @@ -275,6 +291,37 @@ private function validateOrder(OrderDomainObject $order): void } } + /** + * @throws ResourceConflictException + */ + private function validateOccurrenceStatus(OrderDomainObject $order): void + { + $occurrenceIds = $order->getOrderItems() + ?->map(fn(OrderItemDomainObject $item) => $item->getEventOccurrenceId()) + ->filter() + ->unique() + ->values(); + + if ($occurrenceIds === null || $occurrenceIds->isEmpty()) { + return; + } + + $occurrences = $this->occurrenceRepository->findWhereIn('id', $occurrenceIds->toArray()); + + foreach ($occurrences as $occurrence) { + if ($occurrence->isCancelled()) { + throw new ResourceConflictException(__('This event date has been cancelled')); + } + + // Reservation could have been created before the occurrence ended + // — re-check on completion so a reserved order cannot age into a + // valid purchase for a session that has since passed. + if ($occurrence->isPast()) { + throw new ResourceConflictException(__('This event date has already ended')); + } + } + } + /** * @throws ResourceConflictException */ diff --git a/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php b/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php index 5278ef2243..ec4c5f65ae 100644 --- a/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php +++ b/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php @@ -132,24 +132,34 @@ public function validateEventStatus(EventDomainObject $event, CreateOrderPublicD */ private function validateProductAvailability(int $eventId, CreateOrderPublicDTO $createOrderPublicDTO): void { - $availability = $this->availableProductQuantitiesFetchService - ->getAvailableProductQuantities($eventId, ignoreCache: true); - - foreach ($createOrderPublicDTO->products as $product) { - foreach ($product->quantities as $priceQuantity) { - if ($priceQuantity->quantity <= 0) { - continue; - } - - $available = $availability->productQuantities - ->where('product_id', $product->product_id) - ->where('price_id', $priceQuantity->price_id) - ->first()?->quantity_available ?? 0; - - if ($priceQuantity->quantity > $available) { - throw ValidationException::withMessages([ - 'products' => __('Not enough products available. Please try again.'), - ]); + $productsByOccurrence = $createOrderPublicDTO->products->groupBy( + fn(DTO\ProductOrderDetailsDTO $p) => $p->event_occurrence_id + ); + + foreach ($productsByOccurrence as $occurrenceId => $products) { + $availability = $this->availableProductQuantitiesFetchService + ->getAvailableProductQuantities( + $eventId, + ignoreCache: true, + eventOccurrenceId: $occurrenceId ?: null, + ); + + foreach ($products as $product) { + foreach ($product->quantities as $priceQuantity) { + if ($priceQuantity->quantity <= 0) { + continue; + } + + $available = $availability->productQuantities + ->where('product_id', $product->product_id) + ->where('price_id', $priceQuantity->price_id) + ->first()?->quantity_available ?? 0; + + if ($priceQuantity->quantity > $available) { + throw ValidationException::withMessages([ + 'products' => __('Not enough products available. Please try again.'), + ]); + } } } } diff --git a/backend/app/Services/Application/Handlers/Order/DTO/ProductOrderDetailsDTO.php b/backend/app/Services/Application/Handlers/Order/DTO/ProductOrderDetailsDTO.php index 5fecfac13f..61e8094144 100644 --- a/backend/app/Services/Application/Handlers/Order/DTO/ProductOrderDetailsDTO.php +++ b/backend/app/Services/Application/Handlers/Order/DTO/ProductOrderDetailsDTO.php @@ -10,9 +10,10 @@ class ProductOrderDetailsDTO extends BaseDTO { public function __construct( - public readonly int $product_id, + public readonly int $product_id, #[CollectionOf(OrderProductPriceDTO::class)] - public Collection $quantities, + public Collection $quantities, + public readonly ?int $event_occurrence_id = null, ) { } diff --git a/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php b/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php index 0672a0e995..0f97b0db56 100644 --- a/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php +++ b/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\Generated\EventDomainObjectAbstract; use HiEvents\DomainObjects\Generated\OrganizerDomainObjectAbstract; @@ -75,12 +76,22 @@ private function getOrderDomainObject(GetOrderPublicDTO $getOrderData): ?OrderDo ) ], name: ProductDomainObjectAbstract::SINGULAR_NAME, - ) + ), + new Relationship( + domainObject: EventOccurrenceDomainObject::class, + name: 'event_occurrence', + ), ], )) ->loadRelation(new Relationship(domainObject: InvoiceDomainObject::class)) ->loadRelation(new Relationship( domainObject: OrderItemDomainObject::class, + nested: [ + new Relationship( + domainObject: EventOccurrenceDomainObject::class, + name: 'event_occurrence', + ), + ], )); if ($getOrderData->includeEventInResponse) { diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php index 47ccbde563..dcfd7689b5 100644 --- a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php +++ b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php @@ -6,12 +6,12 @@ use Brick\Math\Exception\NumberFormatException; use Brick\Math\Exception\RoundingNecessaryException; use Brick\Money\Exception\UnknownCurrencyException; -use HiEvents\DomainObjects\AccountConfigurationDomainObject; -use HiEvents\DomainObjects\AccountStripePlatformDomainObject; -use HiEvents\DomainObjects\AccountVatSettingDomainObject; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\Generated\StripePaymentDomainObjectAbstract; use HiEvents\DomainObjects\OrderItemDomainObject; +use HiEvents\DomainObjects\OrganizerConfigurationDomainObject; +use HiEvents\DomainObjects\OrganizerStripePlatformDomainObject; +use HiEvents\DomainObjects\OrganizerVatSettingDomainObject; use HiEvents\DomainObjects\Status\OrderStatus; use HiEvents\DomainObjects\StripePaymentDomainObject; use HiEvents\Exceptions\ResourceConflictException; @@ -20,6 +20,7 @@ use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\AccountRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; +use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; use HiEvents\Repository\Interfaces\StripePaymentsRepositoryInterface; use HiEvents\Services\Domain\Payment\Stripe\DTOs\CreatePaymentIntentRequestDTO; use HiEvents\Services\Domain\Payment\Stripe\DTOs\CreatePaymentIntentResponseDTO; @@ -40,6 +41,7 @@ public function __construct( private CheckoutSessionManagementService $sessionIdentifierService, private StripePaymentsRepositoryInterface $stripePaymentsRepository, private AccountRepositoryInterface $accountRepository, + private OrganizerRepositoryInterface $organizerRepository, private StripeClientFactory $stripeClientFactory, private StripeConfigurationService $stripeConfigurationService, ) @@ -47,8 +49,6 @@ public function __construct( } /** - * @param string $orderShortId - * @return CreatePaymentIntentResponseDTO * @throws CreatePaymentIntentFailedException * @throws MathException * @throws NumberFormatException @@ -73,32 +73,30 @@ public function handle(string $orderShortId): CreatePaymentIntentResponseDTO throw new ResourceConflictException(__('Sorry, is expired or not in a valid state.')); } - $account = $this->accountRepository + $event = $order->getEvent(); + + $organizer = $this->organizerRepository + ->loadRelation(OrganizerStripePlatformDomainObject::class) ->loadRelation(new Relationship( - domainObject: AccountConfigurationDomainObject::class, - name: 'configuration', + domainObject: OrganizerConfigurationDomainObject::class, + name: 'organizer_configuration', )) - ->loadRelation(AccountStripePlatformDomainObject::class) ->loadRelation(new Relationship( - domainObject: AccountVatSettingDomainObject::class, - name: 'account_vat_setting', + domainObject: OrganizerVatSettingDomainObject::class, + name: 'organizer_vat_setting', )) - ->findByEventId($order->getEventId()); + ->findById($event->getOrganizerId()); - $stripePlatform = $account->getActiveStripePlatform() - ?? $this->stripeConfigurationService->getPrimaryPlatform(); + $account = $this->accountRepository->findByEventId($order->getEventId()); - $stripeAccountId = $account->getActiveStripeAccountId(); + $stripePlatform = $organizer?->getActiveStripePlatform() + ?? $this->stripeConfigurationService->getPrimaryPlatform(); - // If no platform is configured, we can still process payments with regular Stripe keys - if (!$stripePlatform) { - $stripePlatform = null; // This will use default keys in StripeClientFactory - } + $stripeAccountId = $organizer?->getActiveStripeAccountId(); $stripeClient = $this->stripeClientFactory->createForPlatform($stripePlatform); $publicKey = $this->stripeConfigurationService->getPublicKey($stripePlatform); - // If we already have a Stripe session then re-fetch the client secret if ($order->getStripePayment() !== null) { return new CreatePaymentIntentResponseDTO( paymentIntentId: $order->getStripePayment()->getPaymentIntentId(), @@ -126,8 +124,9 @@ public function handle(string $orderShortId): CreatePaymentIntentResponseDTO 'currencyCode' => $order->getCurrency(), 'account' => $account, 'order' => $order, + 'configuration' => $organizer?->getOrganizerConfiguration(), 'stripeAccountId' => $stripeAccountId, - 'vatSettings' => $account->getAccountVatSetting(), + 'vatSettings' => $organizer?->getOrganizerVatSetting(), 'description' => Str::limit($description, 997), ]) ); diff --git a/backend/app/Services/Application/Handlers/Organizer/CreateOrganizerHandler.php b/backend/app/Services/Application/Handlers/Organizer/CreateOrganizerHandler.php index 4e8441641b..d6228b2636 100644 --- a/backend/app/Services/Application/Handlers/Organizer/CreateOrganizerHandler.php +++ b/backend/app/Services/Application/Handlers/Organizer/CreateOrganizerHandler.php @@ -4,18 +4,24 @@ use HiEvents\DomainObjects\ImageDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; +use HiEvents\Repository\Interfaces\AccountRepositoryInterface; +use HiEvents\Repository\Interfaces\OrganizerConfigurationRepositoryInterface; use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; use HiEvents\Services\Application\Handlers\Organizer\DTO\CreateOrganizerDTO; use HiEvents\Services\Domain\Organizer\CreateDefaultOrganizerSettingsService; use Illuminate\Database\DatabaseManager; +use Psr\Log\LoggerInterface; use Throwable; class CreateOrganizerHandler { public function __construct( - private readonly OrganizerRepositoryInterface $organizerRepository, - private readonly DatabaseManager $databaseManager, - private readonly CreateDefaultOrganizerSettingsService $createDefaultOrganizerSettingsService, + private readonly OrganizerRepositoryInterface $organizerRepository, + private readonly OrganizerConfigurationRepositoryInterface $organizerConfigurationRepository, + private readonly AccountRepositoryInterface $accountRepository, + private readonly DatabaseManager $databaseManager, + private readonly CreateDefaultOrganizerSettingsService $createDefaultOrganizerSettingsService, + private readonly LoggerInterface $logger, ) { } @@ -41,6 +47,7 @@ private function createOrganizer(CreateOrganizerDTO $organizerData): OrganizerDo 'account_id' => $organizerData->account_id, 'timezone' => $organizerData->timezone, 'currency' => $organizerData->currency, + 'organizer_configuration_id' => $this->resolveConfigurationId($organizerData->account_id), ]); $this->createDefaultOrganizerSettingsService->createOrganizerSettings($organizer); @@ -49,4 +56,39 @@ private function createOrganizer(CreateOrganizerDTO $organizerData): OrganizerDo ->loadRelation(ImageDomainObject::class) ->findById($organizer->getId()); } + + /** + * Prefer the parent account's plan via the legacy id pointer kept on + * organizer_configurations during the deprecation window — handles SaaS + * invite tokens that pre-assign a custom account_configuration. Falls back + * to the organizer-level system default. + */ + private function resolveConfigurationId(int $accountId): ?int + { + $account = $this->accountRepository->findFirst($accountId); + $legacyAccountConfigurationId = $account?->getAccountConfigurationId(); + + if ($legacyAccountConfigurationId !== null) { + $matched = $this->organizerConfigurationRepository->findFirstWhere([ + 'legacy_account_configuration_id' => $legacyAccountConfigurationId, + ]); + + if ($matched !== null) { + return $matched->getId(); + } + } + + $defaultConfiguration = $this->organizerConfigurationRepository->findFirstWhere([ + 'is_system_default' => true, + ]); + + if ($defaultConfiguration === null) { + $this->logger->error('No default organizer configuration found while creating organizer', [ + 'account_id' => $accountId, + ]); + return null; + } + + return $defaultConfiguration->getId(); + } } diff --git a/backend/app/Services/Application/Handlers/Organizer/GetOrganizerEventsHandler.php b/backend/app/Services/Application/Handlers/Organizer/GetOrganizerEventsHandler.php index 06a24e2a27..51525ee5fa 100644 --- a/backend/app/Services/Application/Handlers/Organizer/GetOrganizerEventsHandler.php +++ b/backend/app/Services/Application/Handlers/Organizer/GetOrganizerEventsHandler.php @@ -2,6 +2,7 @@ namespace HiEvents\Services\Application\Handlers\Organizer; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\ImageDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; @@ -21,6 +22,7 @@ public function __construct( public function handle(GetOrganizerEventsDTO $dto): LengthAwarePaginator { return $this->eventRepository + ->loadRelation(new Relationship(EventOccurrenceDomainObject::class)) ->loadRelation(new Relationship(ImageDomainObject::class)) ->loadRelation(new Relationship(EventSettingDomainObject::class)) ->loadRelation(new Relationship( diff --git a/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/CopyStripeConnectAccountHandler.php b/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/CopyStripeConnectAccountHandler.php new file mode 100644 index 0000000000..1b6bf9fed3 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/CopyStripeConnectAccountHandler.php @@ -0,0 +1,130 @@ +config->get('app.saas_mode_enabled')) { + throw new SaasModeEnabledException( + __('Stripe Connect Account creation is only available in Saas Mode.'), + ); + } + + return $this->databaseManager->transaction(fn() => $this->copy($command)); + } + + /** + * @throws ResourceNotFoundException + * @throws ResourceConflictException + */ + private function copy(CopyStripeConnectAccountDTO $command): CreateStripeConnectAccountResponse + { + $target = $this->organizerRepository + ->loadRelation(OrganizerStripePlatformDomainObject::class) + ->findFirstWhere([ + 'id' => $command->targetOrganizerId, + 'account_id' => $command->accountId, + ]); + + if ($target === null) { + throw new ResourceNotFoundException(__('Organizer not found.')); + } + + $source = $this->organizerRepository + ->loadRelation(OrganizerStripePlatformDomainObject::class) + ->findFirstWhere([ + 'id' => $command->sourceOrganizerId, + 'account_id' => $command->accountId, + ]); + + if ($source === null) { + throw new ResourceNotFoundException(__('The source organizer was not found.')); + } + + $sourcePlatform = $source->getPrimaryStripePlatform(); + if ($sourcePlatform === null) { + throw new ResourceConflictException( + __('The selected organizer does not have a connected Stripe account.'), + ); + } + + $existing = $target->getOrganizerStripePlatforms() + ?->first(fn(OrganizerStripePlatformDomainObject $row) => $row->getStripeAccountId() === $sourcePlatform->getStripeAccountId()); + + if ($existing !== null) { + $this->organizerStripePlatformRepository->updateWhere( + attributes: [ + OrganizerStripePlatformDomainObjectAbstract::STRIPE_CONNECT_ACCOUNT_TYPE => $sourcePlatform->getStripeConnectAccountType(), + OrganizerStripePlatformDomainObjectAbstract::STRIPE_CONNECT_PLATFORM => $sourcePlatform->getStripeConnectPlatform(), + OrganizerStripePlatformDomainObjectAbstract::STRIPE_SETUP_COMPLETED_AT => $sourcePlatform->getStripeSetupCompletedAt(), + OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_DETAILS => $sourcePlatform->getStripeAccountDetails(), + ], + where: [ + 'id' => $existing->getId(), + ], + ); + } else { + $this->organizerStripePlatformRepository->create([ + OrganizerStripePlatformDomainObjectAbstract::ORGANIZER_ID => $target->getId(), + OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $sourcePlatform->getStripeAccountId(), + OrganizerStripePlatformDomainObjectAbstract::STRIPE_CONNECT_ACCOUNT_TYPE => $sourcePlatform->getStripeConnectAccountType(), + OrganizerStripePlatformDomainObjectAbstract::STRIPE_CONNECT_PLATFORM => $sourcePlatform->getStripeConnectPlatform(), + OrganizerStripePlatformDomainObjectAbstract::STRIPE_SETUP_COMPLETED_AT => $sourcePlatform->getStripeSetupCompletedAt(), + OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_DETAILS => $sourcePlatform->getStripeAccountDetails(), + ]); + } + + // Mirror the connect/webhook flow: when an organizer first acquires a Stripe + // connection (even via copy), seed an empty VAT row if the country is EU. + // We read country from the cached account details since we don't fetch + // the live Stripe account here. + $sourceDetails = $sourcePlatform->getStripeAccountDetails(); + if (is_string($sourceDetails)) { + $sourceDetails = json_decode($sourceDetails, true) ?: []; + } elseif (!is_array($sourceDetails)) { + $sourceDetails = []; + } + + $this->stripeAccountSyncService->seedVatSettingForOrganizerIfMissing( + organizerId: (int)$target->getId(), + countryCode: $sourceDetails['country'] ?? null, + stripeAccountId: $sourcePlatform->getStripeAccountId(), + ); + + return new CreateStripeConnectAccountResponse( + stripeConnectAccountType: $sourcePlatform->getStripeConnectAccountType() ?? '', + stripeAccountId: $sourcePlatform->getStripeAccountId() ?? '', + organizer: $target, + isConnectSetupComplete: $sourcePlatform->getStripeSetupCompletedAt() !== null, + ); + } +} diff --git a/backend/app/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandler.php b/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/CreateStripeConnectAccountHandler.php similarity index 53% rename from backend/app/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandler.php rename to backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/CreateStripeConnectAccountHandler.php index 4f1819e25c..18d19d4b56 100644 --- a/backend/app/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandler.php +++ b/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/CreateStripeConnectAccountHandler.php @@ -1,20 +1,21 @@ accountRepository - ->loadRelation(AccountStripePlatformDomainObject::class) - ->findById($command->accountId); + $organizer = $this->organizerRepository + ->loadRelation(OrganizerStripePlatformDomainObject::class) + ->findFirstWhere([ + 'id' => $command->organizerId, + 'account_id' => $command->accountId, + ]); + + if ($organizer === null) { + throw new ResourceNotFoundException(__('Organizer not found.')); + } - // If platform is explicitly specified (e.g., for Ireland migration), use it - // Otherwise, use the primary platform from environment (Or null for open-source installations) if ($command->platform) { $platformToUse = StripePlatform::fromString($command->platform->value); } else { $platformToUse = $this->stripeConfigurationService->getPrimaryPlatform(); } - // Try to find existing platform record for the requested platform - // This works for both null (open-source) and specific platforms - $accountStripePlatform = $account->getStripePlatformByType($platformToUse); + $organizerStripePlatform = $organizer->getStripePlatformByType($platformToUse); - // Open-source installations without platform configuration should still work - // They will use default Stripe keys instead of platform-specific ones $stripeClient = $this->stripeClientFactory->createForPlatform($platformToUse); $stripeConnectAccount = $this->getOrCreateStripeConnectAccount( - account: $account, - accountStripePlatform: $accountStripePlatform, + organizer: $organizer, + organizerStripePlatform: $organizerStripePlatform, stripeClient: $stripeClient, platform: $platformToUse, ); @@ -90,20 +94,19 @@ private function createOrGetStripeConnectAccount(CreateStripeConnectAccountDTO $ $response = new CreateStripeConnectAccountResponse( stripeConnectAccountType: $stripeConnectAccount->type, stripeAccountId: $stripeConnectAccount->id, - account: $account, + organizer: $organizer, isConnectSetupComplete: $this->stripeAccountSyncService->isStripeAccountComplete($stripeConnectAccount), ); if ($response->isConnectSetupComplete) { - // If setup is complete, but this isn't reflected in the account stripe platform, update it. - if ($accountStripePlatform && $accountStripePlatform->getStripeSetupCompletedAt() === null) { - $this->stripeAccountSyncService->markAccountAsComplete($accountStripePlatform, $stripeConnectAccount); + if ($organizerStripePlatform && $organizerStripePlatform->getStripeSetupCompletedAt() === null) { + $this->stripeAccountSyncService->markAccountAsCompleteForOrganizer($organizerStripePlatform, $stripeConnectAccount); } return $response; } - $connectUrl = $this->stripeAccountSyncService->createStripeAccountSetupUrl($stripeConnectAccount, $stripeClient); + $connectUrl = $this->stripeAccountSyncService->createStripeAccountSetupUrl($stripeConnectAccount, $stripeClient, $organizer->getId()); if ($connectUrl === null) { throw new CreateStripeConnectAccountLinksFailedException( message: __('There are issues with creating the Stripe Connect Account Link. Please try again.'), @@ -119,15 +122,15 @@ private function createOrGetStripeConnectAccount(CreateStripeConnectAccountDTO $ * @throws CreateStripeConnectAccountFailedException */ private function getOrCreateStripeConnectAccount( - AccountDomainObject $account, - ?AccountStripePlatformDomainObject $accountStripePlatform, - StripeClient $stripeClient, - ?StripePlatform $platform + OrganizerDomainObject $organizer, + ?OrganizerStripePlatformDomainObject $organizerStripePlatform, + StripeClient $stripeClient, + ?StripePlatform $platform ): Account { try { - if ($accountStripePlatform && $accountStripePlatform->getStripeAccountId() !== null) { - return $stripeClient->accounts->retrieve($accountStripePlatform->getStripeAccountId()); + if ($organizerStripePlatform && $organizerStripePlatform->getStripeAccountId() !== null) { + return $stripeClient->accounts->retrieve($organizerStripePlatform->getStripeAccountId()); } $stripeAccount = $stripeClient->accounts->create([ @@ -136,9 +139,8 @@ private function getOrCreateStripeConnectAccount( ]); } catch (Throwable $e) { $this->logger->error('Failed to create or fetch Stripe Connect Account: ' . $e->getMessage(), [ - 'accountId' => $account->getId(), - 'stripeAccountId' => $accountStripePlatform?->getStripeAccountId() ?? 'null', - 'accountExists' => $accountStripePlatform?->getStripeAccountId() !== null ? 'true' : 'false', + 'organizerId' => $organizer->getId(), + 'stripeAccountId' => $organizerStripePlatform?->getStripeAccountId() ?? 'null', 'platform' => $platform?->value ?? 'null', 'exception' => $e, ]); @@ -149,28 +151,25 @@ private function getOrCreateStripeConnectAccount( ); } - // Create or update account stripe platform record - if (!$accountStripePlatform) { - $this->accountStripePlatformRepository->create([ - AccountStripePlatformDomainObjectAbstract::ACCOUNT_ID => $account->getId(), - AccountStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id, - AccountStripePlatformDomainObjectAbstract::STRIPE_CONNECT_ACCOUNT_TYPE => $stripeAccount->type, - AccountStripePlatformDomainObjectAbstract::STRIPE_CONNECT_PLATFORM => $platform?->value, + if (!$organizerStripePlatform) { + $this->organizerStripePlatformRepository->create([ + OrganizerStripePlatformDomainObjectAbstract::ORGANIZER_ID => $organizer->getId(), + OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id, + OrganizerStripePlatformDomainObjectAbstract::STRIPE_CONNECT_ACCOUNT_TYPE => $stripeAccount->type, + OrganizerStripePlatformDomainObjectAbstract::STRIPE_CONNECT_PLATFORM => $platform?->value, ]); } else { - $this->accountStripePlatformRepository->updateWhere( + $this->organizerStripePlatformRepository->updateWhere( attributes: [ - AccountStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id, - AccountStripePlatformDomainObjectAbstract::STRIPE_CONNECT_ACCOUNT_TYPE => $stripeAccount->type, + OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id, + OrganizerStripePlatformDomainObjectAbstract::STRIPE_CONNECT_ACCOUNT_TYPE => $stripeAccount->type, ], where: [ - 'id' => $accountStripePlatform->getId(), + 'id' => $organizerStripePlatform->getId(), ] ); } return $stripeAccount; } - - } diff --git a/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/DTO/CopyStripeConnectAccountDTO.php b/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/DTO/CopyStripeConnectAccountDTO.php new file mode 100644 index 0000000000..f962afc00e --- /dev/null +++ b/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/DTO/CopyStripeConnectAccountDTO.php @@ -0,0 +1,16 @@ +organizerRepository + ->loadRelation(OrganizerStripePlatformDomainObject::class) + ->findFirstWhere([ + 'id' => $organizerId, + 'account_id' => $accountId, + ]); + + if ($organizer === null) { + throw new ResourceNotFoundException(__('Organizer not found.')); + } + + $stripeConnectAccounts = $this->getStripeConnectAccounts($organizer); + $primaryStripeAccountId = $organizer->getActiveStripeAccountId(); + $hasCompletedSetup = $organizer->isStripeSetupComplete(); + $reusable = $this->getReusableConnections($accountId, $organizerId, $primaryStripeAccountId); + + return new GetStripeConnectAccountsResponseDTO( + organizer: $organizer, + stripeConnectAccounts: $stripeConnectAccounts, + reusableConnections: $reusable, + primaryStripeAccountId: $primaryStripeAccountId, + hasCompletedSetup: $hasCompletedSetup, + ); + } + + private function getStripeConnectAccounts(OrganizerDomainObject $organizer): Collection + { + $stripeAccounts = collect(); + $stripePlatforms = $organizer->getOrganizerStripePlatforms(); + + if (!$stripePlatforms || $stripePlatforms->isEmpty()) { + return $stripeAccounts; + } + + foreach ($stripePlatforms as $stripePlatform) { + $stripeAccount = $this->buildStripeAccountDTO($stripePlatform); + if ($stripeAccount) { + $stripeAccounts->push($stripeAccount); + } + } + + return $stripeAccounts; + } + + private function buildStripeAccountDTO(OrganizerStripePlatformDomainObject $stripePlatform): ?StripeConnectAccountDTO + { + if (!$stripePlatform->getStripeAccountId()) { + return null; + } + + try { + $platform = $stripePlatform->getStripeConnectPlatform() + ? StripePlatform::fromString($stripePlatform->getStripeConnectPlatform()) + : null; + + $stripeClient = $this->stripeClientFactory->createForPlatform($platform); + $stripeAccount = $stripeClient->accounts->retrieve($stripePlatform->getStripeAccountId()); + + $isSetupComplete = $this->stripeAccountSyncService->isStripeAccountComplete($stripeAccount); + $connectUrl = null; + + if ($isSetupComplete && $stripePlatform->getStripeSetupCompletedAt() === null) { + $this->stripeAccountSyncService->markAccountAsCompleteForOrganizer($stripePlatform, $stripeAccount); + } else { + $this->stripeAccountSyncService->syncStripeAccountDetailsForOrganizer($stripePlatform, $stripeAccount); + } + + if (!$isSetupComplete) { + $connectUrl = $this->stripeAccountSyncService->createStripeAccountSetupUrl($stripeAccount, $stripeClient, $stripePlatform->getOrganizerId()); + } + + $details = is_array($stripePlatform->getStripeAccountDetails()) + ? $stripePlatform->getStripeAccountDetails() + : (is_string($stripePlatform->getStripeAccountDetails()) + ? (json_decode($stripePlatform->getStripeAccountDetails(), true) ?? []) + : []); + + return new StripeConnectAccountDTO( + stripeAccountId: $stripeAccount->id, + connectUrl: $connectUrl, + isSetupComplete: $isSetupComplete, + platform: $platform, + accountType: $stripeAccount->type, + isPrimary: $isSetupComplete, + country: $stripeAccount->country ?? ($details['country'] ?? null), + businessType: $stripeAccount->business_type ?? ($details['business_type'] ?? null), + chargesEnabled: (bool)($stripeAccount->charges_enabled ?? false), + payoutsEnabled: (bool)($stripeAccount->payouts_enabled ?? false), + capabilities: $this->normalizeCapabilities($stripeAccount), + requirements: $this->normalizeRequirements($stripeAccount), + ); + } catch (StripeClientConfigurationException $e) { + $this->logger->warning('Failed to retrieve Stripe account due to configuration issue', [ + 'stripe_account_id' => $stripePlatform->getStripeAccountId(), + 'platform' => $stripePlatform->getStripeConnectPlatform(), + 'error' => $e->getMessage(), + ]); + return null; + } catch (Throwable $e) { + $this->logger->error('Failed to retrieve Stripe account', [ + 'stripe_account_id' => $stripePlatform->getStripeAccountId(), + 'platform' => $stripePlatform->getStripeConnectPlatform(), + 'error' => $e->getMessage(), + ]); + return null; + } + } + + private function normalizeCapabilities(\Stripe\Account $stripeAccount): array + { + $capabilities = $stripeAccount->capabilities; + if (is_array($capabilities)) { + return $capabilities; + } + if ($capabilities && method_exists($capabilities, 'toArray')) { + return $capabilities->toArray(); + } + return []; + } + + private function normalizeRequirements(\Stripe\Account $stripeAccount): array + { + $requirements = $stripeAccount->requirements; + return [ + 'currently_due' => $requirements?->currently_due ?? [], + 'eventually_due' => $requirements?->eventually_due ?? [], + 'past_due' => $requirements?->past_due ?? [], + 'pending_verification' => $requirements?->pending_verification ?? [], + ]; + } + + private function getReusableConnections(int $accountId, int $excludeOrganizerId, ?string $currentStripeAccountId): Collection + { + $rows = $this->organizerStripePlatformRepository->findReusableForAccount( + $accountId, + $excludeOrganizerId, + $currentStripeAccountId, + ); + + $seen = []; + $result = collect(); + + foreach ($rows as $row) { + $stripeAccountId = $row->stripe_account_id; + if (isset($seen[$stripeAccountId])) { + continue; + } + $seen[$stripeAccountId] = true; + + $details = $row->stripe_account_details; + if (is_string($details)) { + $details = json_decode($details, true) ?? []; + } + if (!is_array($details)) { + $details = []; + } + + $result->push(new ReusableStripeConnectionDTO( + organizerId: (int)$row->organizer_id, + organizerName: (string)$row->organizer_name, + stripeAccountId: $stripeAccountId, + platform: $row->stripe_connect_platform, + country: $details['country'] ?? null, + businessType: $details['business_type'] ?? null, + )); + } + + return $result; + } +} diff --git a/backend/app/Services/Application/Handlers/Organizer/Vat/DTO/UpsertOrganizerVatSettingDTO.php b/backend/app/Services/Application/Handlers/Organizer/Vat/DTO/UpsertOrganizerVatSettingDTO.php new file mode 100644 index 0000000000..35ed3d3e0b --- /dev/null +++ b/backend/app/Services/Application/Handlers/Organizer/Vat/DTO/UpsertOrganizerVatSettingDTO.php @@ -0,0 +1,17 @@ +vatSettingRepository->findByOrganizerId($organizerId); + } +} diff --git a/backend/app/Services/Application/Handlers/Account/Vat/UpsertAccountVatSettingHandler.php b/backend/app/Services/Application/Handlers/Organizer/Vat/UpsertOrganizerVatSettingHandler.php similarity index 75% rename from backend/app/Services/Application/Handlers/Account/Vat/UpsertAccountVatSettingHandler.php rename to backend/app/Services/Application/Handlers/Organizer/Vat/UpsertOrganizerVatSettingHandler.php index 72c7c4029f..198df6d28a 100644 --- a/backend/app/Services/Application/Handlers/Account/Vat/UpsertAccountVatSettingHandler.php +++ b/backend/app/Services/Application/Handlers/Organizer/Vat/UpsertOrganizerVatSettingHandler.php @@ -2,31 +2,47 @@ declare(strict_types=1); -namespace HiEvents\Services\Application\Handlers\Account\Vat; +namespace HiEvents\Services\Application\Handlers\Organizer\Vat; -use HiEvents\DomainObjects\AccountVatSettingDomainObject; +use HiEvents\DomainObjects\OrganizerVatSettingDomainObject; use HiEvents\DomainObjects\Status\VatValidationStatus; +use HiEvents\Exceptions\ResourceNotFoundException; use HiEvents\Jobs\Vat\ValidateVatNumberJob; -use HiEvents\Repository\Interfaces\AccountVatSettingRepositoryInterface; -use HiEvents\Services\Application\Handlers\Account\Vat\DTO\UpsertAccountVatSettingDTO; +use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; +use HiEvents\Repository\Interfaces\OrganizerVatSettingRepositoryInterface; +use HiEvents\Services\Application\Handlers\Organizer\Vat\DTO\UpsertOrganizerVatSettingDTO; use HiEvents\Services\Infrastructure\Vat\ViesValidationService; use Psr\Log\LoggerInterface; -class UpsertAccountVatSettingHandler +class UpsertOrganizerVatSettingHandler { public function __construct( - private readonly AccountVatSettingRepositoryInterface $vatSettingRepository, - private readonly ViesValidationService $viesValidationService, - private readonly LoggerInterface $logger, - ) { + private readonly OrganizerVatSettingRepositoryInterface $vatSettingRepository, + private readonly OrganizerRepositoryInterface $organizerRepository, + private readonly ViesValidationService $viesValidationService, + private readonly LoggerInterface $logger, + ) + { } - public function handle(UpsertAccountVatSettingDTO $command): AccountVatSettingDomainObject + /** + * @throws ResourceNotFoundException + */ + public function handle(UpsertOrganizerVatSettingDTO $command): OrganizerVatSettingDomainObject { - $existing = $this->vatSettingRepository->findByAccountId($command->accountId); + $organizer = $this->organizerRepository->findFirstWhere([ + 'id' => $command->organizerId, + 'account_id' => $command->accountId, + ]); + + if ($organizer === null) { + throw new ResourceNotFoundException(__('Organizer not found.')); + } + + $existing = $this->vatSettingRepository->findByOrganizerId($command->organizerId); $data = [ - 'account_id' => $command->accountId, + 'organizer_id' => $command->organizerId, 'vat_registered' => $command->vatRegistered, ]; @@ -78,8 +94,8 @@ public function handle(UpsertAccountVatSettingDTO $command): AccountVatSettingDo if ($shouldValidate && $data['vat_validation_status'] === VatValidationStatus::PENDING->value) { $this->logger->info('Sync validation failed, dispatching VAT validation job', [ - 'account_vat_setting_id' => $vatSetting->getId(), - 'account_id' => $command->accountId, + 'organizer_vat_setting_id' => $vatSetting->getId(), + 'organizer_id' => $command->organizerId, 'vat_number_masked' => $this->maskVatNumber($vatNumber), ]); @@ -101,11 +117,6 @@ private function trySyncValidation(string $vatNumber, array $data): array $result = $this->viesValidationService->validateVatNumber($vatNumber); if ($result->valid) { - $this->logger->info('Sync VAT validation successful', [ - 'vat_number_masked' => $this->maskVatNumber($vatNumber), - 'business_name' => $result->businessName, - ]); - $data['vat_validated'] = true; $data['vat_validation_status'] = VatValidationStatus::VALID->value; $data['vat_validation_error'] = null; @@ -118,11 +129,6 @@ private function trySyncValidation(string $vatNumber, array $data): array } if ($result->isTransientError) { - $this->logger->info('Sync VAT validation hit transient error, will queue for retry', [ - 'vat_number_masked' => $this->maskVatNumber($vatNumber), - 'error' => $result->errorMessage, - ]); - $data['vat_validated'] = false; $data['vat_validation_status'] = VatValidationStatus::PENDING->value; $data['vat_validation_error'] = $result->errorMessage; @@ -134,11 +140,6 @@ private function trySyncValidation(string $vatNumber, array $data): array return $data; } - $this->logger->info('Sync VAT validation failed - invalid VAT number', [ - 'vat_number_masked' => $this->maskVatNumber($vatNumber), - 'error' => $result->errorMessage, - ]); - $data['vat_validated'] = false; $data['vat_validation_status'] = VatValidationStatus::INVALID->value; $data['vat_validation_error'] = $result->errorMessage; diff --git a/backend/app/Services/Application/Handlers/Reports/DTO/GetReportDTO.php b/backend/app/Services/Application/Handlers/Reports/DTO/GetReportDTO.php index 295c9b4da9..c843eff009 100644 --- a/backend/app/Services/Application/Handlers/Reports/DTO/GetReportDTO.php +++ b/backend/app/Services/Application/Handlers/Reports/DTO/GetReportDTO.php @@ -11,7 +11,8 @@ public function __construct( public readonly int $eventId, public readonly ReportTypes $reportType, public readonly ?string $startDate, - public readonly ?string $endDate + public readonly ?string $endDate, + public readonly ?int $occurrenceId = null, ) { } diff --git a/backend/app/Services/Application/Handlers/Reports/GetReportHandler.php b/backend/app/Services/Application/Handlers/Reports/GetReportHandler.php index 9541081ac0..6a59abaea1 100644 --- a/backend/app/Services/Application/Handlers/Reports/GetReportHandler.php +++ b/backend/app/Services/Application/Handlers/Reports/GetReportHandler.php @@ -23,6 +23,7 @@ public function handle(GetReportDTO $reportData): Collection eventId: $reportData->eventId, startDate: $reportData->startDate ? Carbon::parse($reportData->startDate) : null, endDate: $reportData->endDate ? Carbon::parse($reportData->endDate) : null, + occurrenceId: $reportData->occurrenceId, ); } } diff --git a/backend/app/Services/Application/Handlers/SelfService/ResendAttendeeTicketPublicHandler.php b/backend/app/Services/Application/Handlers/SelfService/ResendAttendeeTicketPublicHandler.php index e6a1da06e9..75b5e706a1 100644 --- a/backend/app/Services/Application/Handlers/SelfService/ResendAttendeeTicketPublicHandler.php +++ b/backend/app/Services/Application/Handlers/SelfService/ResendAttendeeTicketPublicHandler.php @@ -2,7 +2,11 @@ namespace HiEvents\Services\Application\Handlers\SelfService; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\Generated\AttendeeDomainObjectAbstract; +use HiEvents\DomainObjects\Status\AttendeeStatus; +use HiEvents\Exceptions\ResourceConflictException; +use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; @@ -19,28 +23,51 @@ public function __construct( private readonly OrderRepositoryInterface $orderRepository, private readonly EventRepositoryInterface $eventRepository, private readonly SelfServiceResendEmailService $selfServiceResendEmailService, - ) { - } + ) {} + /** + * @throws ResourceConflictException + */ public function handle(ResendEmailPublicDTO $dto): void { $this->loadAndValidateEvent($dto->eventId); $order = $this->loadAndValidateOrder($dto->orderShortId, $dto->eventId); - if (!$dto->attendeeShortId) { + if ($order->isOrderCancelled()) { + throw new ResourceConflictException( + __('Tickets can\'t be resent for a cancelled order.') + ); + } + + if (! $dto->attendeeShortId) { throw new ResourceNotFoundException(__('Attendee not found')); } - $attendee = $this->attendeeRepository->findFirstWhere([ - AttendeeDomainObjectAbstract::SHORT_ID => $dto->attendeeShortId, - AttendeeDomainObjectAbstract::ORDER_ID => $order->getId(), - AttendeeDomainObjectAbstract::EVENT_ID => $dto->eventId, - ]); + $attendee = $this->attendeeRepository + ->loadRelation(new Relationship(EventOccurrenceDomainObject::class, name: 'event_occurrence')) + ->findFirstWhere([ + AttendeeDomainObjectAbstract::SHORT_ID => $dto->attendeeShortId, + AttendeeDomainObjectAbstract::ORDER_ID => $order->getId(), + AttendeeDomainObjectAbstract::EVENT_ID => $dto->eventId, + ]); - if (!$attendee) { + if (! $attendee) { throw new ResourceNotFoundException(__('Attendee not found')); } + if ($attendee->getStatus() === AttendeeStatus::CANCELLED->name) { + throw new ResourceConflictException( + __('This ticket has been cancelled and can\'t be resent.') + ); + } + + $occurrence = $attendee->getEventOccurrence(); + if ($occurrence?->isCancelled()) { + throw new ResourceConflictException( + __('The session for this ticket has been cancelled and can\'t be resent.') + ); + } + $this->selfServiceResendEmailService->resendAttendeeTicket( attendeeId: $attendee->getId(), orderId: $order->getId(), diff --git a/backend/app/Services/Application/Handlers/Waitlist/CreateWaitlistEntryHandler.php b/backend/app/Services/Application/Handlers/Waitlist/CreateWaitlistEntryHandler.php index 7813771413..415a920df2 100644 --- a/backend/app/Services/Application/Handlers/Waitlist/CreateWaitlistEntryHandler.php +++ b/backend/app/Services/Application/Handlers/Waitlist/CreateWaitlistEntryHandler.php @@ -3,21 +3,31 @@ namespace HiEvents\Services\Application\Handlers\Waitlist; use HiEvents\DomainObjects\WaitlistEntryDomainObject; +use HiEvents\DomainObjects\Enums\EventType; +use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract; use HiEvents\Exceptions\ResourceConflictException; use HiEvents\Exceptions\ResourceNotFoundException; +use HiEvents\Repository\Eloquent\Value\OrderAndDirection; +use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; +use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface; use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use HiEvents\Services\Application\Handlers\Waitlist\DTO\CreateWaitlistEntryDTO; +use HiEvents\Services\Domain\EventOccurrence\OccurrencePurchaseEligibilityService; use HiEvents\Services\Domain\Waitlist\CreateWaitlistEntryService; +use Illuminate\Validation\ValidationException; class CreateWaitlistEntryHandler { public function __construct( private readonly CreateWaitlistEntryService $createWaitlistEntryService, private readonly EventSettingsRepositoryInterface $eventSettingsRepository, + private readonly EventRepositoryInterface $eventRepository, private readonly ProductPriceRepositoryInterface $productPriceRepository, private readonly ProductRepositoryInterface $productRepository, + private readonly EventOccurrenceRepositoryInterface $occurrenceRepository, + private readonly OccurrencePurchaseEligibilityService $occurrenceEligibilityService, ) { } @@ -28,6 +38,36 @@ public function __construct( */ public function handle(CreateWaitlistEntryDTO $dto): WaitlistEntryDomainObject { + $event = $this->eventRepository->findById($dto->event_id); + if ($event !== null && $event->isRecurring() && $dto->event_occurrence_id === null) { + throw ValidationException::withMessages([ + 'event_occurrence_id' => __('An event date must be selected.'), + ]); + } + + if ($event !== null + && $event->getType() === EventType::SINGLE->name + && $dto->event_occurrence_id === null + ) { + $occurrence = $this->occurrenceRepository + ->findWhere( + where: [ + EventOccurrenceDomainObjectAbstract::EVENT_ID => $dto->event_id, + ], + orderAndDirections: [ + new OrderAndDirection(EventOccurrenceDomainObjectAbstract::START_DATE, 'asc'), + ], + ) + ->first(); + + if ($occurrence !== null) { + $dto = CreateWaitlistEntryDTO::fromArray(array_merge( + $dto->toArray(), + ['event_occurrence_id' => $occurrence->getId()], + )); + } + } + $eventSettings = $this->eventSettingsRepository->findFirstWhere([ 'event_id' => $dto->event_id, ]); @@ -43,6 +83,24 @@ public function handle(CreateWaitlistEntryDTO $dto): WaitlistEntryDomainObject throw new ResourceNotFoundException(__('Product not found for this event')); } + if ($dto->event_occurrence_id !== null) { + $occurrence = $this->occurrenceRepository->findFirstWhere([ + 'id' => $dto->event_occurrence_id, + 'event_id' => $dto->event_id, + ]); + + if ($occurrence === null || $occurrence->isCancelled() || $occurrence->isPast()) { + throw ValidationException::withMessages([ + 'event_occurrence_id' => __('This event date is no longer available.'), + ]); + } + + $this->occurrenceEligibilityService->assertProductsVisibleOnOccurrence( + $dto->event_occurrence_id, + [$product->getId()], + ); + } + return $this->createWaitlistEntryService->createEntry($dto, $eventSettings, $product); } } diff --git a/backend/app/Services/Application/Handlers/Waitlist/DTO/CreateWaitlistEntryDTO.php b/backend/app/Services/Application/Handlers/Waitlist/DTO/CreateWaitlistEntryDTO.php index a8da7ff6e1..b048599f16 100644 --- a/backend/app/Services/Application/Handlers/Waitlist/DTO/CreateWaitlistEntryDTO.php +++ b/backend/app/Services/Application/Handlers/Waitlist/DTO/CreateWaitlistEntryDTO.php @@ -13,6 +13,7 @@ public function __construct( public string $first_name, public ?string $last_name = null, public string $locale = 'en', + public ?int $event_occurrence_id = null, ) { } diff --git a/backend/app/Services/Application/Handlers/Waitlist/DTO/OfferWaitlistEntryDTO.php b/backend/app/Services/Application/Handlers/Waitlist/DTO/OfferWaitlistEntryDTO.php index 84e7e1eed8..b8e58cb5c6 100644 --- a/backend/app/Services/Application/Handlers/Waitlist/DTO/OfferWaitlistEntryDTO.php +++ b/backend/app/Services/Application/Handlers/Waitlist/DTO/OfferWaitlistEntryDTO.php @@ -11,6 +11,7 @@ public function __construct( public ?int $product_price_id = null, public ?int $entry_id = null, public int $quantity = 1, + public ?int $event_occurrence_id = null, ) { } diff --git a/backend/app/Services/Application/Handlers/Waitlist/GetWaitlistStatsHandler.php b/backend/app/Services/Application/Handlers/Waitlist/GetWaitlistStatsHandler.php index 69e56fab92..a71b9950e9 100644 --- a/backend/app/Services/Application/Handlers/Waitlist/GetWaitlistStatsHandler.php +++ b/backend/app/Services/Application/Handlers/Waitlist/GetWaitlistStatsHandler.php @@ -17,12 +17,16 @@ public function __construct( { } - public function handle(int $eventId): WaitlistStatsDTO + public function handle(int $eventId, ?int $eventOccurrenceId = null): WaitlistStatsDTO { - $stats = $this->waitlistEntryRepository->getStatsByEventId($eventId); - $productRows = $this->waitlistEntryRepository->getProductStatsByEventId($eventId); + $stats = $this->waitlistEntryRepository->getStatsByEventId($eventId, $eventOccurrenceId); + $productRows = $this->waitlistEntryRepository->getProductStatsByEventId($eventId, $eventOccurrenceId); - $quantities = $this->availableQuantitiesService->getAvailableProductQuantities($eventId, ignoreCache: true); + $quantities = $this->availableQuantitiesService->getAvailableProductQuantities( + $eventId, + ignoreCache: true, + eventOccurrenceId: $eventOccurrenceId, + ); $products = $productRows->map(function ($row) use ($quantities) { $actualAvailable = $this->getAvailableCountForPrice($quantities, (int) $row->product_price_id); diff --git a/backend/app/Services/Application/Handlers/Waitlist/OfferWaitlistEntryHandler.php b/backend/app/Services/Application/Handlers/Waitlist/OfferWaitlistEntryHandler.php index 1a6ad7bf2b..1132c4ada8 100644 --- a/backend/app/Services/Application/Handlers/Waitlist/OfferWaitlistEntryHandler.php +++ b/backend/app/Services/Application/Handlers/Waitlist/OfferWaitlistEntryHandler.php @@ -44,6 +44,7 @@ public function handle(OfferWaitlistEntryDTO $dto): Collection quantity: $dto->quantity, event: $event, eventSettings: $eventSettings, + eventOccurrenceId: $dto->event_occurrence_id, ); } } diff --git a/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php b/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php index 7dceaaef96..a1f0b05298 100644 --- a/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php +++ b/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php @@ -32,7 +32,8 @@ public function send( $order, $event, $eventSettings, - $organizer + $organizer, + $attendee->getEventOccurrence(), ); $this->mailer diff --git a/backend/app/Services/Domain/CheckInList/CheckInListDataService.php b/backend/app/Services/Domain/CheckInList/CheckInListDataService.php index 840a3bd7c1..ead3ccde7c 100644 --- a/backend/app/Services/Domain/CheckInList/CheckInListDataService.php +++ b/backend/app/Services/Domain/CheckInList/CheckInListDataService.php @@ -30,15 +30,40 @@ public function verifyAttendeeBelongsToCheckInList( AttendeeDomainObject $attendee, ): void { - $allowedProductIds = $checkInList->getProducts()->map(fn($product) => $product->getId())->toArray() ?? []; + $allowedProductIds = $checkInList->getProducts()?->map(fn($product) => $product->getId())->toArray() ?? []; - if (!in_array($attendee->getProductId(), $allowedProductIds, true)) { + // A list with zero product attachments covers every ticket on the event; + // we only reject when it has specific product scope AND the attendee's + // product isn't in it. + if (!empty($allowedProductIds) && !in_array($attendee->getProductId(), $allowedProductIds, true)) { throw new CannotCheckInException( __('Attendee :attendee_name is not allowed to check in using this check-in list', [ 'attendee_name' => $attendee->getFullName(), ]) ); } + + // Belt-and-braces when the list covers all tickets: the attendee's + // event must match the list's event. Normally the data model already + // guarantees this via the product FK, but for the empty-attachments + // case we have no product chain to rely on. + if (empty($allowedProductIds) && $attendee->getEventId() !== $checkInList->getEventId()) { + throw new CannotCheckInException( + __('Attendee :attendee_name does not belong to this event', [ + 'attendee_name' => $attendee->getFullName(), + ]) + ); + } + + if ($checkInList->getEventOccurrenceId() !== null + && $attendee->getEventOccurrenceId() !== $checkInList->getEventOccurrenceId() + ) { + throw new CannotCheckInException( + __(':attendee_name\'s ticket is for a different session — check they\'re on the right check-in list.', [ + 'attendee_name' => $attendee->getFullName(), + ]) + ); + } } /** diff --git a/backend/app/Services/Domain/CheckInList/CheckInListProductAssociationService.php b/backend/app/Services/Domain/CheckInList/CheckInListProductAssociationService.php index 2153c44620..0eb0036ef4 100644 --- a/backend/app/Services/Domain/CheckInList/CheckInListProductAssociationService.php +++ b/backend/app/Services/Domain/CheckInList/CheckInListProductAssociationService.php @@ -35,16 +35,16 @@ private function associateProductsWithCheckInList( bool $removePreviousAssignments = true ): void { - if (empty($productIds)) { - return; - } - if ($removePreviousAssignments) { $this->productRepository->removeCheckInListFromProducts( checkInListId: $checkInListId, ); } + if (empty($productIds)) { + return; + } + $this->productRepository->addCheckInListToProducts( checkInListId: $checkInListId, productIds: array_unique($productIds), diff --git a/backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInService.php b/backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInService.php index 47c25356b6..9f776ca34e 100644 --- a/backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInService.php +++ b/backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInService.php @@ -262,6 +262,7 @@ private function createCheckIn( AttendeeCheckInDomainObjectAbstract::PRODUCT_ID => $attendee->getProductId(), AttendeeCheckInDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::CHECK_IN_PREFIX), AttendeeCheckInDomainObjectAbstract::EVENT_ID => $checkInList->getEventId(), + AttendeeCheckInDomainObjectAbstract::EVENT_OCCURRENCE_ID => $attendee->getEventOccurrenceId(), ]); } } diff --git a/backend/app/Services/Domain/CheckInList/CreateCheckInListService.php b/backend/app/Services/Domain/CheckInList/CreateCheckInListService.php index a4e95f929c..abd12c745a 100644 --- a/backend/app/Services/Domain/CheckInList/CreateCheckInListService.php +++ b/backend/app/Services/Domain/CheckInList/CreateCheckInListService.php @@ -15,12 +15,11 @@ class CreateCheckInListService { public function __construct( - private readonly CheckInListRepositoryInterface $checkInListRepository, - private readonly EventProductValidationService $eventProductValidationService, + private readonly CheckInListRepositoryInterface $checkInListRepository, + private readonly EventProductValidationService $eventProductValidationService, private readonly CheckInListProductAssociationService $checkInListProductAssociationService, private readonly DatabaseManager $databaseManager, private readonly EventRepositoryInterface $eventRepository, - ) { } @@ -38,6 +37,7 @@ public function createCheckInList(CheckInListDomainObject $checkInList, array $p CheckInListDomainObjectAbstract::NAME => $checkInList->getName(), CheckInListDomainObjectAbstract::DESCRIPTION => $checkInList->getDescription(), CheckInListDomainObjectAbstract::EVENT_ID => $checkInList->getEventId(), + CheckInListDomainObjectAbstract::EVENT_OCCURRENCE_ID => $checkInList->getEventOccurrenceId(), CheckInListDomainObjectAbstract::EXPIRES_AT => $checkInList->getExpiresAt() ? DateHelper::convertToUTC($checkInList->getExpiresAt(), $event->getTimezone()) : null, @@ -45,6 +45,9 @@ public function createCheckInList(CheckInListDomainObject $checkInList, array $p ? DateHelper::convertToUTC($checkInList->getActivatesAt(), $event->getTimezone()) : null, CheckInListDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::CHECK_IN_LIST_PREFIX), + CheckInListDomainObjectAbstract::PUBLIC_SHOW_ATTENDEE_NOTES => $checkInList->getPublicShowAttendeeNotes(), + CheckInListDomainObjectAbstract::PUBLIC_SHOW_QUESTION_ANSWERS => $checkInList->getPublicShowQuestionAnswers(), + CheckInListDomainObjectAbstract::PUBLIC_SHOW_ORDER_DETAILS => $checkInList->getPublicShowOrderDetails(), ]); $this->checkInListProductAssociationService->addCheckInListToProducts( diff --git a/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php b/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php index 11a441deaf..a3c7191518 100644 --- a/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php +++ b/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php @@ -37,12 +37,16 @@ public function updateCheckInList(CheckInListDomainObject $checkInList, array $p CheckInListDomainObjectAbstract::NAME => $checkInList->getName(), CheckInListDomainObjectAbstract::DESCRIPTION => $checkInList->getDescription(), CheckInListDomainObjectAbstract::EVENT_ID => $checkInList->getEventId(), + CheckInListDomainObjectAbstract::EVENT_OCCURRENCE_ID => $checkInList->getEventOccurrenceId(), CheckInListDomainObjectAbstract::EXPIRES_AT => $checkInList->getExpiresAt() ? DateHelper::convertToUTC($checkInList->getExpiresAt(), $event->getTimezone()) : null, CheckInListDomainObjectAbstract::ACTIVATES_AT => $checkInList->getActivatesAt() ? DateHelper::convertToUTC($checkInList->getActivatesAt(), $event->getTimezone()) : null, + CheckInListDomainObjectAbstract::PUBLIC_SHOW_ATTENDEE_NOTES => $checkInList->getPublicShowAttendeeNotes(), + CheckInListDomainObjectAbstract::PUBLIC_SHOW_QUESTION_ANSWERS => $checkInList->getPublicShowQuestionAnswers(), + CheckInListDomainObjectAbstract::PUBLIC_SHOW_ORDER_DETAILS => $checkInList->getPublicShowOrderDetails(), ], where: [ CheckInListDomainObjectAbstract::ID => $checkInList->getId(), diff --git a/backend/app/Services/Domain/Email/EmailTemplateService.php b/backend/app/Services/Domain/Email/EmailTemplateService.php index 9d864dc8c4..797b685575 100644 --- a/backend/app/Services/Domain/Email/EmailTemplateService.php +++ b/backend/app/Services/Domain/Email/EmailTemplateService.php @@ -151,6 +151,10 @@ private function getDefaultCTAs(): array 'label' => __('View Ticket'), 'url_token' => 'ticket.url', ], + EmailTemplateType::OCCURRENCE_CANCELLATION->value => [ + 'label' => __('View Event'), + 'url_token' => 'event.url', + ], ]; } @@ -191,6 +195,23 @@ private function getDefaultTemplates(): array If you have any questions or need assistance, please contact {{ settings.support_email }}.
+Best regards,
+{{ organizer.name }} +LIQUID + ], + EmailTemplateType::OCCURRENCE_CANCELLATION->value => [ + 'subject' => '{{ event.title }} on {{ occurrence.start_date }} has been cancelled', + 'body' => <<<'LIQUID' +Hello,
+ +We're sorry to let you know that {{ event.title }} scheduled for {{ occurrence.start_date }} at {{ occurrence.start_time }} has been cancelled.
+ +{% if cancellation.refund_issued %} +A refund for your order will be processed automatically. Please allow a few business days for the refund to appear on your statement.
+{% else %} +If you have any questions about your order, please respond to this email or contact {{ settings.support_email }}.
+{% endif %} + Best regards,
{{ organizer.name }} LIQUID diff --git a/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php b/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php index c0f71aa1a1..d9ea3e0d46 100644 --- a/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php +++ b/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php @@ -6,6 +6,7 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\Enums\PaymentProviders; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; @@ -23,18 +24,21 @@ public function buildOrderConfirmationContext( OrderDomainObject $order, EventDomainObject $event, OrganizerDomainObject $organizer, - EventSettingDomainObject $eventSettings + EventSettingDomainObject $eventSettings, + ?EventOccurrenceDomainObject $occurrence = null, ): array { - $eventStartDate = new Carbon(DateHelper::convertFromUTC($event->getStartDate(), $event->getTimezone())); - $eventEndDate = $event->getEndDate() ? new Carbon(DateHelper::convertFromUTC($event->getEndDate(), $event->getTimezone())) : null; + $startDateRaw = $occurrence?->getStartDate() ?? $event->getStartDate(); + $endDateRaw = $occurrence?->getEndDate() ?? $event->getEndDate(); + + $eventStartDate = $startDateRaw ? new Carbon(DateHelper::convertFromUTC($startDateRaw, $event->getTimezone())) : null; + $eventEndDate = $endDateRaw ? new Carbon(DateHelper::convertFromUTC($endDateRaw, $event->getTimezone())) : null; return [ - // Event object 'event' => [ - 'title' => $event->getTitle(), - 'date' => $eventStartDate->format('F j, Y'), - 'time' => $eventStartDate->format('g:i A'), + 'title' => $event->getTitle() . ($occurrence?->getLabel() ? ' - ' . $occurrence->getLabel() : ''), + 'date' => $eventStartDate?->format('F j, Y') ?? '', + 'time' => $eventStartDate?->format('g:i A') ?? '', 'end_date' => $eventEndDate?->format('F j, Y') ?? '', 'end_time' => $eventEndDate?->format('g:i A') ?? '', 'full_address' => $eventSettings->getLocationDetails() ? AddressHelper::formatAddress($eventSettings->getLocationDetails()) : '', @@ -43,7 +47,6 @@ public function buildOrderConfirmationContext( 'timezone' => $event->getTimezone(), ], - // Order object 'order' => [ 'url' => sprintf( Url::getFrontEndUrlFromConfig(Url::ORDER_SUMMARY), @@ -53,8 +56,8 @@ public function buildOrderConfirmationContext( 'number' => $order->getPublicId(), 'total' => Currency::format($order->getTotalGross(), $event->getCurrency()), 'date' => (new Carbon($order->getCreatedAt()))->format('F j, Y'), - 'currency' => $order->getCurrency(), // added - 'locale' => $order->getLocale(), // added + 'currency' => $order->getCurrency(), + 'locale' => $order->getLocale(), 'first_name' => $order->getFirstName() ?? '', 'last_name' => $order->getLastName() ?? '', 'email' => $order->getEmail() ?? '', @@ -62,18 +65,24 @@ public function buildOrderConfirmationContext( 'is_offline_payment' => $order->getPaymentProvider() === PaymentProviders::OFFLINE->value, ], - // Organizer object 'organizer' => [ 'name' => $organizer->getName() ?? '', 'email' => $organizer->getEmail() ?? '', ], - // Settings object 'settings' => [ 'support_email' => $eventSettings->getSupportEmail() ?? $organizer->getEmail() ?? '', 'offline_payment_instructions' => $eventSettings->getOfflinePaymentInstructions() ?? '', 'post_checkout_message' => $eventSettings->getPostCheckoutMessage() ?? '', ], + + 'occurrence' => [ + 'start_date' => $eventStartDate?->format('F j, Y') ?? '', + 'start_time' => $eventStartDate?->format('g:i A') ?? '', + 'end_date' => $eventEndDate?->format('F j, Y') ?? '', + 'end_time' => $eventEndDate?->format('g:i A') ?? '', + 'label' => $occurrence?->getLabel() ?? '', + ], ]; } @@ -82,10 +91,11 @@ public function buildAttendeeTicketContext( OrderDomainObject $order, EventDomainObject $event, OrganizerDomainObject $organizer, - EventSettingDomainObject $eventSettings + EventSettingDomainObject $eventSettings, + ?EventOccurrenceDomainObject $occurrence = null, ): array { - $baseContext = $this->buildOrderConfirmationContext($order, $event, $organizer, $eventSettings); + $baseContext = $this->buildOrderConfirmationContext($order, $event, $organizer, $eventSettings, $occurrence); /** @var OrderItemDomainObject $orderItem */ $orderItem = $order->getOrderItems()->first(fn(OrderItemDomainObject $item) => $item->getProductPriceId() === $attendee->getProductPriceId()); @@ -112,6 +122,61 @@ public function buildAttendeeTicketContext( return $baseContext; } + public function buildOccurrenceCancellationContext( + EventDomainObject $event, + EventOccurrenceDomainObject $occurrence, + OrganizerDomainObject $organizer, + EventSettingDomainObject $eventSettings, + bool $refundOrders = false, + ): array + { + $startDateRaw = $occurrence->getStartDate(); + $endDateRaw = $occurrence->getEndDate(); + + $eventStartDate = $startDateRaw ? new Carbon(DateHelper::convertFromUTC($startDateRaw, $event->getTimezone())) : null; + $eventEndDate = $endDateRaw ? new Carbon(DateHelper::convertFromUTC($endDateRaw, $event->getTimezone())) : null; + + return [ + 'event' => [ + 'title' => $event->getTitle() . ($occurrence->getLabel() ? ' - ' . $occurrence->getLabel() : ''), + 'date' => $eventStartDate?->format('F j, Y') ?? '', + 'time' => $eventStartDate?->format('g:i A') ?? '', + 'end_date' => $eventEndDate?->format('F j, Y') ?? '', + 'end_time' => $eventEndDate?->format('g:i A') ?? '', + 'full_address' => $eventSettings->getLocationDetails() ? AddressHelper::formatAddress($eventSettings->getLocationDetails()) : '', + 'location_details' => $eventSettings->getLocationDetails(), + 'description' => $event->getDescription() ?? '', + 'timezone' => $event->getTimezone(), + 'url' => sprintf( + Url::getFrontEndUrlFromConfig(Url::EVENT_HOMEPAGE), + $event->getId(), + $event->getSlug(), + ), + ], + + 'occurrence' => [ + 'start_date' => $eventStartDate?->format('F j, Y') ?? '', + 'start_time' => $eventStartDate?->format('g:i A') ?? '', + 'end_date' => $eventEndDate?->format('F j, Y') ?? '', + 'end_time' => $eventEndDate?->format('g:i A') ?? '', + 'label' => $occurrence->getLabel() ?? '', + ], + + 'organizer' => [ + 'name' => $organizer->getName() ?? '', + 'email' => $organizer->getEmail() ?? '', + ], + + 'settings' => [ + 'support_email' => $eventSettings->getSupportEmail() ?? $organizer->getEmail() ?? '', + ], + + 'cancellation' => [ + 'refund_issued' => $refundOrders, + ], + ]; + } + public function buildPreviewContext(string $templateType): array { $baseContext = [ @@ -158,6 +223,14 @@ public function buildPreviewContext(string $templateType): array ], ]; + $baseContext['occurrence'] = [ + 'start_date' => 'April 25, 2029', + 'start_time' => '7:00 PM', + 'end_date' => 'April 26, 2029', + 'end_time' => '11:00 PM', + 'label' => 'Session A', + ]; + if ($templateType === 'attendee_ticket') { $baseContext['attendee'] = [ 'name' => 'John Smith', @@ -170,6 +243,13 @@ public function buildPreviewContext(string $templateType): array ]; } + if ($templateType === 'occurrence_cancellation') { + $baseContext['cancellation'] = [ + 'refund_issued' => true, + ]; + $baseContext['event']['url'] = 'https://example.com/event/123/summer-fest'; + } + return $baseContext; } } diff --git a/backend/app/Services/Domain/Email/MailBuilderService.php b/backend/app/Services/Domain/Email/MailBuilderService.php index 59ef8806b3..135612260f 100644 --- a/backend/app/Services/Domain/Email/MailBuilderService.php +++ b/backend/app/Services/Domain/Email/MailBuilderService.php @@ -2,14 +2,18 @@ namespace HiEvents\Services\Domain\Email; +use Carbon\Carbon; use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\Enums\EmailTemplateType; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\InvoiceDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; +use HiEvents\Helper\DateHelper; use HiEvents\Mail\Attendee\AttendeeTicketMail; +use HiEvents\Mail\Occurrence\OccurrenceCancellationMail; use HiEvents\Mail\Order\OrderSummary; use HiEvents\Services\Domain\Email\DTO\RenderedEmailTemplateDTO; @@ -26,14 +30,16 @@ public function buildAttendeeTicketMail( OrderDomainObject $order, EventDomainObject $event, EventSettingDomainObject $eventSettings, - OrganizerDomainObject $organizer + OrganizerDomainObject $organizer, + ?EventOccurrenceDomainObject $occurrence = null, ): AttendeeTicketMail { $renderedTemplate = $this->renderAttendeeTicketTemplate( $attendee, $order, $event, $eventSettings, - $organizer + $organizer, + $occurrence, ); return new AttendeeTicketMail( @@ -43,6 +49,7 @@ public function buildAttendeeTicketMail( eventSettings: $eventSettings, organizer: $organizer, renderedTemplate: $renderedTemplate, + occurrence: $occurrence, ); } @@ -51,13 +58,15 @@ public function buildOrderSummaryMail( EventDomainObject $event, EventSettingDomainObject $eventSettings, OrganizerDomainObject $organizer, - ?InvoiceDomainObject $invoice = null + ?InvoiceDomainObject $invoice = null, + ?EventOccurrenceDomainObject $occurrence = null, ): OrderSummary { $renderedTemplate = $this->renderOrderSummaryTemplate( $order, $event, $eventSettings, - $organizer + $organizer, + $occurrence, ); return new OrderSummary( @@ -66,6 +75,7 @@ public function buildOrderSummaryMail( organizer: $organizer, eventSettings: $eventSettings, invoice: $invoice, + occurrence: $occurrence, renderedTemplate: $renderedTemplate, ); } @@ -75,7 +85,8 @@ private function renderAttendeeTicketTemplate( OrderDomainObject $order, EventDomainObject $event, EventSettingDomainObject $eventSettings, - OrganizerDomainObject $organizer + OrganizerDomainObject $organizer, + ?EventOccurrenceDomainObject $occurrence = null, ): ?RenderedEmailTemplateDTO { $template = $this->emailTemplateService->getTemplateByType( type: EmailTemplateType::ATTENDEE_TICKET, @@ -93,7 +104,8 @@ private function renderAttendeeTicketTemplate( $order, $event, $organizer, - $eventSettings + $eventSettings, + $occurrence, ); return $this->emailTemplateService->renderTemplate($template, $context); @@ -103,7 +115,8 @@ private function renderOrderSummaryTemplate( OrderDomainObject $order, EventDomainObject $event, EventSettingDomainObject $eventSettings, - OrganizerDomainObject $organizer + OrganizerDomainObject $organizer, + ?EventOccurrenceDomainObject $occurrence = null, ): ?RenderedEmailTemplateDTO { $template = $this->emailTemplateService->getTemplateByType( type: EmailTemplateType::ORDER_CONFIRMATION, @@ -120,7 +133,66 @@ private function renderOrderSummaryTemplate( $order, $event, $organizer, - $eventSettings + $eventSettings, + $occurrence, + ); + + return $this->emailTemplateService->renderTemplate($template, $context); + } + + public function buildOccurrenceCancellationMail( + EventDomainObject $event, + EventOccurrenceDomainObject $occurrence, + OrganizerDomainObject $organizer, + EventSettingDomainObject $eventSettings, + bool $refundOrders = false, + ): OccurrenceCancellationMail { + $renderedTemplate = $this->renderOccurrenceCancellationTemplate( + $event, + $occurrence, + $eventSettings, + $organizer, + $refundOrders, + ); + + $startDate = DateHelper::convertFromUTC($occurrence->getStartDate(), $event->getTimezone()); + $formattedDate = (new Carbon($startDate))->format('F j, Y g:i A'); + + return new OccurrenceCancellationMail( + event: $event, + occurrence: $occurrence, + organizer: $organizer, + eventSettings: $eventSettings, + formattedDate: $formattedDate, + refundOrders: $refundOrders, + renderedTemplate: $renderedTemplate, + ); + } + + private function renderOccurrenceCancellationTemplate( + EventDomainObject $event, + EventOccurrenceDomainObject $occurrence, + EventSettingDomainObject $eventSettings, + OrganizerDomainObject $organizer, + bool $refundOrders = false, + ): ?RenderedEmailTemplateDTO { + $template = $this->emailTemplateService->getTemplateByType( + type: EmailTemplateType::OCCURRENCE_CANCELLATION, + accountId: $event->getAccountId(), + eventId: $event->getId(), + organizerId: $organizer->getId() + ); + + if (!$template) { + return null; + } + + $context = $this->tokenContextBuilder->buildOccurrenceCancellationContext( + $event, + $occurrence, + $organizer, + $eventSettings, + $refundOrders, ); return $this->emailTemplateService->renderTemplate($template, $context); diff --git a/backend/app/Services/Domain/Event/CreateEventService.php b/backend/app/Services/Domain/Event/CreateEventService.php index 7126eda943..ed3f57ba8c 100644 --- a/backend/app/Services/Domain/Event/CreateEventService.php +++ b/backend/app/Services/Domain/Event/CreateEventService.php @@ -2,6 +2,8 @@ namespace HiEvents\Services\Domain\Event; +use HiEvents\DomainObjects\Enums\AttendeeDetailsCollectionMethod; +use HiEvents\DomainObjects\Enums\EventType; use HiEvents\DomainObjects\Enums\HomepageBackgroundType; use HiEvents\DomainObjects\Enums\ImageType; use HiEvents\DomainObjects\Enums\PaymentProviders; @@ -12,6 +14,8 @@ use HiEvents\Exceptions\OrganizerNotFoundException; use HiEvents\Helper\DateHelper; use HiEvents\Helper\IdHelper; +use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface; use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface; @@ -26,34 +30,35 @@ class CreateEventService { public function __construct( - private readonly EventRepositoryInterface $eventRepository, - private readonly EventSettingsRepositoryInterface $eventSettingsRepository, - private readonly OrganizerRepositoryInterface $organizerRepository, - private readonly DatabaseManager $databaseManager, + private readonly EventRepositoryInterface $eventRepository, + private readonly EventSettingsRepositoryInterface $eventSettingsRepository, + private readonly OrganizerRepositoryInterface $organizerRepository, + private readonly DatabaseManager $databaseManager, private readonly EventStatisticRepositoryInterface $eventStatisticsRepository, - private readonly HtmlPurifierService $purifier, - private readonly ImageRepositoryInterface $imageRepository, - private readonly Repository $config, - private readonly FilesystemManager $filesystemManager, - ) - { - } + private readonly HtmlPurifierService $purifier, + private readonly ImageRepositoryInterface $imageRepository, + private readonly Repository $config, + private readonly FilesystemManager $filesystemManager, + private readonly EventOccurrenceRepositoryInterface $occurrenceRepository, + private readonly CheckInListRepositoryInterface $checkInListRepository, + ) {} /** * @throws Throwable */ public function createEvent( - EventDomainObject $eventData, - ?EventSettingDomainObject $eventSettings = null - ): EventDomainObject - { - return $this->databaseManager->transaction(function () use ($eventData, $eventSettings) { + EventDomainObject $eventData, + ?string $startDate = null, + ?string $endDate = null, + ?EventSettingDomainObject $eventSettings = null, + ): EventDomainObject { + return $this->databaseManager->transaction(function () use ($eventData, $startDate, $endDate, $eventSettings) { $organizer = $this->getOrganizer( organizerId: $eventData->getOrganizerId(), accountId: $eventData->getAccountId() ); - $event = $this->handleEventCreate($eventData); + $event = $this->handleEventCreate($eventData, $startDate, $endDate); $eventCoverCreated = $this->createEventCover($event); @@ -66,10 +71,29 @@ public function createEvent( $this->createEventStatistics($event); + $this->createSystemDefaultCheckInList($event); + return $event; }); } + /** + * Every event gets a default "covers every ticket" check-in list at creation + * time so staff can open check-in the moment tickets exist. + */ + private function createSystemDefaultCheckInList(EventDomainObject $event): void + { + $this->checkInListRepository->create([ + 'event_id' => $event->getId(), + 'short_id' => IdHelper::shortId(IdHelper::CHECK_IN_LIST_PREFIX), + 'name' => __('Default check-in'), + 'is_system_default' => true, + 'public_show_attendee_notes' => true, + 'public_show_question_answers' => true, + 'public_show_order_details' => true, + ]); + } + /** * @throws OrganizerNotFoundException */ @@ -91,15 +115,11 @@ private function getOrganizer(int $organizerId, int $accountId): OrganizerDomain return $organizer; } - private function handleEventCreate(EventDomainObject $eventData): EventDomainObject + private function handleEventCreate(EventDomainObject $eventData, ?string $startDate = null, ?string $endDate = null): EventDomainObject { - return $this->eventRepository->create([ + $event = $this->eventRepository->create([ 'title' => $eventData->getTitle(), 'organizer_id' => $eventData->getOrganizerId(), - 'start_date' => DateHelper::convertToUTC($eventData->getStartDate(), $eventData->getTimezone()), - 'end_date' => $eventData->getEndDate() - ? DateHelper::convertToUTC($eventData->getEndDate(), $eventData->getTimezone()) - : null, 'description' => $this->purifier->purify($eventData->getDescription()), 'timezone' => $eventData->getTimezone(), 'currency' => $eventData->getCurrency(), @@ -110,7 +130,23 @@ private function handleEventCreate(EventDomainObject $eventData): EventDomainObj 'status' => $eventData->getStatus(), 'short_id' => IdHelper::shortId(IdHelper::EVENT_PREFIX), 'attributes' => $eventData->getAttributes(), + 'type' => $eventData->getType() ?? EventType::SINGLE->name, + 'recurrence_rule' => $eventData->getRecurrenceRule(), ]); + + if (($eventData->getType() ?? EventType::SINGLE->name) === EventType::SINGLE->name && $startDate !== null) { + $this->occurrenceRepository->create([ + 'event_id' => $event->getId(), + 'short_id' => IdHelper::shortId(IdHelper::OCCURRENCE_PREFIX), + 'start_date' => DateHelper::convertToUTC($startDate, $eventData->getTimezone()), + 'end_date' => $endDate ? DateHelper::convertToUTC($endDate, $eventData->getTimezone()) : null, + 'status' => 'ACTIVE', + 'used_capacity' => 0, + 'is_overridden' => false, + ]); + } + + return $event; } private function createEventStatistics(EventDomainObject $event): void @@ -128,19 +164,16 @@ private function createEventStatistics(EventDomainObject $event): void /** * If a default cover image exists for the event category, it will be created. - * - * @param EventDomainObject $event - * @return bool */ private function createEventCover(EventDomainObject $event): bool { $disk = $this->config->get('filesystems.public'); $defaultCoversPath = $this->config->get('app.event_categories_cover_images_path'); - $imageFilename = $event->getCategory() . '.jpg'; - $imagePath = $defaultCoversPath . '/' . $imageFilename; + $imageFilename = $event->getCategory().'.jpg'; + $imagePath = $defaultCoversPath.'/'.$imageFilename; - if (!$this->filesystemManager->disk($disk)->exists($imagePath)) { + if (! $this->filesystemManager->disk($disk)->exists($imagePath)) { return false; } @@ -161,11 +194,10 @@ private function createEventCover(EventDomainObject $event): bool private function createEventSettings( ?EventSettingDomainObject $eventSettings, - EventDomainObject $event, - OrganizerDomainObject $organizer, - bool $eventCoverCreated = false - ): void - { + EventDomainObject $event, + OrganizerDomainObject $organizer, + bool $eventCoverCreated = false + ): void { if ($eventSettings !== null) { $eventSettings->setEventId($event->getId()); $eventSettingsArray = $eventSettings->toArray(); @@ -220,7 +252,12 @@ private function createEventSettings( 'organization_address' => null, 'invoice_tax_details' => null, - 'attendee_details_collection_method' => $organizerSettings->getDefaultAttendeeDetailsCollectionMethod(), + // Recurring events default to per-order collection — each order typically + // covers multiple sessions, and collecting per-attendee details every time + // is high friction. Single events inherit the organizer-level default. + 'attendee_details_collection_method' => $event->getType() === EventType::RECURRING->name + ? AttendeeDetailsCollectionMethod::PER_ORDER->value + : $organizerSettings->getDefaultAttendeeDetailsCollectionMethod(), 'show_marketing_opt_in' => $organizerSettings->getDefaultShowMarketingOptIn(), 'pass_platform_fee_to_buyer' => $organizerSettings->getDefaultPassPlatformFeeToBuyer(), 'allow_attendee_self_edit' => $organizerSettings->getDefaultAllowAttendeeSelfEdit() ?? false, diff --git a/backend/app/Services/Domain/Event/DTO/DuplicateEventDataDTO.php b/backend/app/Services/Domain/Event/DTO/DuplicateEventDataDTO.php index 003ac4ba20..c8af055156 100644 --- a/backend/app/Services/Domain/Event/DTO/DuplicateEventDataDTO.php +++ b/backend/app/Services/Domain/Event/DTO/DuplicateEventDataDTO.php @@ -21,6 +21,7 @@ public function __construct( public bool $duplicateTicketLogo = true, public bool $duplicateWebhooks = true, public bool $duplicateAffiliates = true, + public bool $duplicateOccurrences = true, public ?string $description = null, public ?string $endDate = null, ) diff --git a/backend/app/Services/Domain/Event/DuplicateEventService.php b/backend/app/Services/Domain/Event/DuplicateEventService.php index 22326cb041..8fb8f6caa5 100644 --- a/backend/app/Services/Domain/Event/DuplicateEventService.php +++ b/backend/app/Services/Domain/Event/DuplicateEventService.php @@ -5,9 +5,11 @@ use HiEvents\DomainObjects\AffiliateDomainObject; use HiEvents\DomainObjects\CapacityAssignmentDomainObject; use HiEvents\DomainObjects\CheckInListDomainObject; +use HiEvents\DomainObjects\Enums\EventType; use HiEvents\DomainObjects\Enums\ImageType; use HiEvents\DomainObjects\Enums\QuestionBelongsTo; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\ImageDomainObject; use HiEvents\DomainObjects\ProductCategoryDomainObject; @@ -15,13 +17,18 @@ use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\DomainObjects\PromoCodeDomainObject; use HiEvents\DomainObjects\QuestionDomainObject; +use HiEvents\DomainObjects\Status\EventOccurrenceStatus; use HiEvents\DomainObjects\Status\EventStatus; use HiEvents\DomainObjects\TaxAndFeesDomainObject; use HiEvents\DomainObjects\WebhookDomainObject; +use HiEvents\Helper\IdHelper; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\AffiliateRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\ImageRepositoryInterface; +use HiEvents\Repository\Interfaces\ProductOccurrenceVisibilityRepositoryInterface; +use HiEvents\Repository\Interfaces\ProductPriceOccurrenceOverrideRepositoryInterface; use HiEvents\Services\Domain\CapacityAssignment\CreateCapacityAssignmentService; use HiEvents\Services\Domain\CheckInList\CreateCheckInListService; use HiEvents\Services\Domain\CreateWebhookService; @@ -36,45 +43,46 @@ class DuplicateEventService { public function __construct( - private readonly EventRepositoryInterface $eventRepository, - private readonly CreateEventService $createEventService, - private readonly CreateProductService $createProductService, - private readonly CreateQuestionService $createQuestionService, - private readonly CreatePromoCodeService $createPromoCodeService, + private readonly EventRepositoryInterface $eventRepository, + private readonly CreateEventService $createEventService, + private readonly CreateProductService $createProductService, + private readonly CreateQuestionService $createQuestionService, + private readonly CreatePromoCodeService $createPromoCodeService, private readonly CreateCapacityAssignmentService $createCapacityAssignmentService, - private readonly CreateCheckInListService $createCheckInListService, - private readonly ImageRepositoryInterface $imageRepository, - private readonly DatabaseManager $databaseManager, - private readonly HtmlPurifierService $purifier, - private readonly CreateProductCategoryService $createProductCategoryService, - private readonly CreateWebhookService $createWebhookService, - private readonly AffiliateRepositoryInterface $affiliateRepository, - ) - { - } + private readonly CreateCheckInListService $createCheckInListService, + private readonly ImageRepositoryInterface $imageRepository, + private readonly DatabaseManager $databaseManager, + private readonly HtmlPurifierService $purifier, + private readonly CreateProductCategoryService $createProductCategoryService, + private readonly CreateWebhookService $createWebhookService, + private readonly AffiliateRepositoryInterface $affiliateRepository, + private readonly EventOccurrenceRepositoryInterface $eventOccurrenceRepository, + private readonly ProductPriceOccurrenceOverrideRepositoryInterface $priceOverrideRepository, + private readonly ProductOccurrenceVisibilityRepositoryInterface $visibilityRepository, + ) {} /** * @throws Throwable */ public function duplicateEvent( - string $eventId, - string $accountId, - string $title, - string $startDate, - bool $duplicateProducts = true, - bool $duplicateQuestions = true, - bool $duplicateSettings = true, - bool $duplicatePromoCodes = true, - bool $duplicateCapacityAssignments = true, - bool $duplicateCheckInLists = true, - bool $duplicateEventCoverImage = true, - bool $duplicateTicketLogo = true, - bool $duplicateWebhooks = true, - bool $duplicateAffiliates = true, + string $eventId, + string $accountId, + string $title, + string $startDate, + bool $duplicateProducts = true, + bool $duplicateQuestions = true, + bool $duplicateSettings = true, + bool $duplicatePromoCodes = true, + bool $duplicateCapacityAssignments = true, + bool $duplicateCheckInLists = true, + bool $duplicateEventCoverImage = true, + bool $duplicateTicketLogo = true, + bool $duplicateWebhooks = true, + bool $duplicateAffiliates = true, + bool $duplicateOccurrences = true, ?string $description = null, ?string $endDate = null, - ): EventDomainObject - { + ): EventDomainObject { try { $this->databaseManager->beginTransaction(); @@ -82,33 +90,45 @@ public function duplicateEvent( $event ->setTitle($title) - ->setStartDate($startDate) - ->setEndDate($endDate) ->setDescription($this->purifier->purify($description)) ->setStatus(EventStatus::DRAFT->name); $newEvent = $this->cloneExistingEvent( event: $event, cloneEventSettings: $duplicateSettings, + startDate: $startDate, + endDate: $endDate, ); + $oldToNewOccurrenceMap = []; + if ($duplicateOccurrences && $event->getType() === EventType::RECURRING->name) { + $oldToNewOccurrenceMap = $this->cloneOccurrences($event, $newEvent->getId()); + } + if ($duplicateQuestions) { $this->clonePerOrderQuestions($event, $newEvent->getId()); } + $oldPriceToNewPriceMap = []; + $oldProductToNewProductMap = []; if ($duplicateProducts) { - $this->cloneExistingProducts( + [$oldProductToNewProductMap, $oldPriceToNewPriceMap] = $this->cloneExistingProducts( event: $event, newEventId: $newEvent->getId(), duplicateQuestions: $duplicateQuestions, duplicatePromoCodes: $duplicatePromoCodes, duplicateCapacityAssignments: $duplicateCapacityAssignments, duplicateCheckInLists: $duplicateCheckInLists, + oldToNewOccurrenceMap: $oldToNewOccurrenceMap, ); } else { $this->createProductCategoryService->createDefaultProductCategory($newEvent); } + if ($duplicateOccurrences && $duplicateProducts && ! empty($oldToNewOccurrenceMap)) { + $this->cloneOccurrenceProductSettings($oldToNewOccurrenceMap, $oldProductToNewProductMap, $oldPriceToNewPriceMap); + } + if ($duplicateEventCoverImage) { $this->cloneEventCoverImage($event, $newEvent->getId()); } @@ -135,48 +155,74 @@ public function duplicateEvent( } /** - * @param EventDomainObject $event - * @param bool $cloneEventSettings - * @return EventDomainObject * @throws Throwable */ - private function cloneExistingEvent(EventDomainObject $event, bool $cloneEventSettings): EventDomainObject - { + private function cloneExistingEvent( + EventDomainObject $event, + bool $cloneEventSettings, + ?string $startDate = null, + ?string $endDate = null, + ): EventDomainObject { return $this->createEventService->createEvent( - eventData: (new EventDomainObject()) + eventData: (new EventDomainObject) ->setOrganizerId($event->getOrganizerId()) ->setAccountId($event->getAccountId()) ->setUserId($event->getUserId()) ->setTitle($event->getTitle()) ->setCategory($event->getCategory()) - ->setStartDate($event->getStartDate()) - ->setEndDate($event->getEndDate()) ->setDescription($event->getDescription()) ->setAttributes($event->getAttributes()) ->setTimezone($event->getTimezone()) ->setCurrency($event->getCurrency()) - ->setStatus($event->getStatus()), + ->setStatus($event->getStatus()) + ->setType($event->getType()) + ->setRecurrenceRule($this->stripStaleExclusions($event->getRecurrenceRule())), + startDate: $startDate, + endDate: $endDate, eventSettings: $cloneEventSettings ? $event->getEventSettings() : null, ); } + /** + * The source's `excluded_dates` and `excluded_occurrences` both correspond + * to occurrences that were cancelled or deleted on the original event — + * those records aren't cloned (we only clone ACTIVE occurrences), so + * carrying their dates as exclusions on the duplicate would forever block + * those dates from regenerating with no record explaining why. Strip both + * on clone — additional_dates stay since they represent rule additions, + * not cancellations. + */ + private function stripStaleExclusions(?array $rule): ?array + { + if ($rule === null) { + return null; + } + unset($rule['excluded_dates'], $rule['excluded_occurrences']); + + return $rule; + } + /** * @throws Throwable */ + /** + * @return array{0: array, 1: array} [$oldProductToNewProductMap, $oldPriceToNewPriceMap] + */ private function cloneExistingProducts( EventDomainObject $event, - int $newEventId, - bool $duplicateQuestions, - bool $duplicatePromoCodes, - bool $duplicateCapacityAssignments, - bool $duplicateCheckInLists, - ): void - { + int $newEventId, + bool $duplicateQuestions, + bool $duplicatePromoCodes, + bool $duplicateCapacityAssignments, + bool $duplicateCheckInLists, + array $oldToNewOccurrenceMap = [], + ): array { $oldProductToNewProductMap = []; + $oldPriceToNewPriceMap = []; - $event->getProductCategories()?->each(function (ProductCategoryDomainObject $productCategory) use ($event, $newEventId, &$oldProductToNewProductMap) { + $event->getProductCategories()?->each(function (ProductCategoryDomainObject $productCategory) use ($event, $newEventId, &$oldProductToNewProductMap, &$oldPriceToNewPriceMap) { $newCategory = $this->createProductCategoryService->createCategory( - (new ProductCategoryDomainObject()) + (new ProductCategoryDomainObject) ->setName($productCategory->getName()) ->setNoProductsMessage($productCategory->getNoProductsMessage()) ->setDescription($productCategory->getDescription()) @@ -191,9 +237,17 @@ private function cloneExistingProducts( $newProduct = $this->createProductService->createProduct( product: $product, accountId: $event->getAccountId(), - taxAndFeeIds: $product->getTaxAndFees()?->map(fn($taxAndFee) => $taxAndFee->getId())?->toArray(), + taxAndFeeIds: $product->getTaxAndFees()?->map(fn ($taxAndFee) => $taxAndFee->getId())?->toArray(), ); $oldProductToNewProductMap[$product->getId()] = $newProduct->getId(); + + $oldPrices = $product->getProductPrices()?->all() ?? []; + $newPrices = $newProduct->getProductPrices()?->all() ?? []; + foreach ($oldPrices as $index => $oldPrice) { + if (isset($newPrices[$index])) { + $oldPriceToNewPriceMap[$oldPrice->getId()] = $newPrices[$index]->getId(); + } + } } }); @@ -210,8 +264,10 @@ private function cloneExistingProducts( } if ($duplicateCheckInLists) { - $this->cloneCheckInLists($event, $newEventId, $oldProductToNewProductMap); + $this->cloneCheckInLists($event, $newEventId, $oldProductToNewProductMap, $oldToNewOccurrenceMap); } + + return [$oldProductToNewProductMap, $oldPriceToNewPriceMap]; } /** @@ -222,7 +278,7 @@ private function clonePerProductQuestions(EventDomainObject $event, int $newEven foreach ($event->getQuestions() as $question) { if ($question->getBelongsTo() === QuestionBelongsTo::PRODUCT->name) { $this->createQuestionService->createQuestion( - (new QuestionDomainObject()) + (new QuestionDomainObject) ->setTitle($question->getTitle()) ->setEventId($newEventId) ->setBelongsTo($question->getBelongsTo()) @@ -231,7 +287,7 @@ private function clonePerProductQuestions(EventDomainObject $event, int $newEven ->setOptions($question->getOptions()) ->setIsHidden($question->getIsHidden()), array_map( - static fn(ProductDomainObject $product) => $oldProductToNewProductMap[$product->getId()], + static fn (ProductDomainObject $product) => $oldProductToNewProductMap[$product->getId()], $question->getProducts()?->all(), ), ); @@ -247,7 +303,7 @@ private function clonePerOrderQuestions(EventDomainObject $event, int $newEventI foreach ($event->getQuestions() as $question) { if ($question->getBelongsTo() === QuestionBelongsTo::ORDER->name) { $this->createQuestionService->createQuestion( - (new QuestionDomainObject()) + (new QuestionDomainObject) ->setTitle($question->getTitle()) ->setDescription($question->getDescription()) ->setEventId($newEventId) @@ -269,11 +325,11 @@ private function clonePromoCodes(EventDomainObject $event, int $newEventId, arra { foreach ($event->getPromoCodes() as $promoCode) { $this->createPromoCodeService->createPromoCode( - (new PromoCodeDomainObject()) + (new PromoCodeDomainObject) ->setCode($promoCode->getCode()) ->setEventId($newEventId) ->setApplicableProductIds(array_map( - static fn($productId) => $oldProductToNewProductMap[$productId], + static fn ($productId) => $oldProductToNewProductMap[$productId], $promoCode->getApplicableProductIds() ?? [], )) ->setDiscountType($promoCode->getDiscountType()) @@ -289,30 +345,51 @@ private function cloneCapacityAssignments(EventDomainObject $event, int $newEven /** @var CapacityAssignmentDomainObject $capacityAssignment */ foreach ($event->getCapacityAssignments() as $capacityAssignment) { $this->createCapacityAssignmentService->createCapacityAssignment( - capacityAssignment: (new CapacityAssignmentDomainObject()) + capacityAssignment: (new CapacityAssignmentDomainObject) ->setName($capacityAssignment->getName()) ->setEventId($newEventId) ->setCapacity($capacityAssignment->getCapacity()) ->setAppliesTo($capacityAssignment->getAppliesTo()) ->setStatus($capacityAssignment->getStatus()), productIds: $capacityAssignment->getProducts() - ?->map(fn($product) => $oldProductToNewProductMap[$product->getId()])?->toArray() ?? [], + ?->map(fn ($product) => $oldProductToNewProductMap[$product->getId()])?->toArray() ?? [], ); } } - private function cloneCheckInLists(EventDomainObject $event, int $newEventId, $oldProductToNewProductMap): void - { + private function cloneCheckInLists( + EventDomainObject $event, + int $newEventId, + array $oldProductToNewProductMap, + array $oldToNewOccurrenceMap = [], + ): void { foreach ($event->getCheckInLists() as $checkInList) { + // CreateEventService already auto-created a system_default list on the + // new event — cloning the source's would produce two equivalent + // "covers every ticket" lists. Skip it. + if ($checkInList->getIsSystemDefault()) { + continue; + } + + // Preserve occurrence scope: a list scoped to one source occurrence + // should map to the cloned occurrence on the duplicate. If the + // source occurrence was filtered out of the clone (cancelled or + // past), drop the scope rather than leaving it stale. + $sourceOccurrenceId = $checkInList->getEventOccurrenceId(); + $newOccurrenceId = $sourceOccurrenceId !== null + ? ($oldToNewOccurrenceMap[$sourceOccurrenceId] ?? null) + : null; + $this->createCheckInListService->createCheckInList( - checkInList: (new CheckInListDomainObject()) + checkInList: (new CheckInListDomainObject) ->setName($checkInList->getName()) ->setDescription($checkInList->getDescription()) ->setExpiresAt($checkInList->getExpiresAt()) ->setActivatesAt($checkInList->getActivatesAt()) + ->setEventOccurrenceId($newOccurrenceId) ->setEventId($newEventId), productIds: $checkInList->getProducts() - ?->map(fn($product) => $oldProductToNewProductMap[$product->getId()])?->toArray() ?? [], + ?->map(fn ($product) => $oldProductToNewProductMap[$product->getId()])?->toArray() ?? [], ); } } @@ -320,7 +397,7 @@ private function cloneCheckInLists(EventDomainObject $event, int $newEventId, $o private function cloneEventCoverImage(EventDomainObject $event, int $newEventId): void { /** @var ImageDomainObject $coverImage */ - $coverImage = $event->getImages()?->first(fn(ImageDomainObject $image) => $image->getType() === ImageType::EVENT_COVER->name); + $coverImage = $event->getImages()?->first(fn (ImageDomainObject $image) => $image->getType() === ImageType::EVENT_COVER->name); if ($coverImage) { $this->imageRepository->create([ 'entity_id' => $newEventId, @@ -338,7 +415,7 @@ private function cloneEventCoverImage(EventDomainObject $event, int $newEventId) private function cloneTicketLogo(EventDomainObject $event, int $newEventId): void { /** @var ImageDomainObject $ticketLogo */ - $ticketLogo = $event->getImages()?->first(fn(ImageDomainObject $image) => $image->getType() === ImageType::TICKET_LOGO->name); + $ticketLogo = $event->getImages()?->first(fn (ImageDomainObject $image) => $image->getType() === ImageType::TICKET_LOGO->name); if ($ticketLogo) { $this->imageRepository->create([ 'entity_id' => $newEventId, @@ -356,6 +433,7 @@ private function cloneTicketLogo(EventDomainObject $event, int $newEventId): voi private function getEventWithRelations(string $eventId, string $accountId): EventDomainObject { return $this->eventRepository + ->loadRelation(EventOccurrenceDomainObject::class) ->loadRelation(EventSettingDomainObject::class) ->loadRelation( new Relationship(ProductCategoryDomainObject::class, [ @@ -388,7 +466,7 @@ private function duplicateWebhooks(EventDomainObject $event, EventDomainObject $ { $event->getWebhooks()?->each(function (WebhookDomainObject $webhook) use ($newEvent) { $this->createWebhookService->createWebhook( - (new WebhookDomainObject()) + (new WebhookDomainObject) ->setEventId($newEvent->getId()) ->setUrl($webhook->getUrl()) ->setSecret($webhook->getSecret()) @@ -413,4 +491,75 @@ private function duplicateAffiliates(EventDomainObject $event, EventDomainObject ]); }); } + + /** + * @return array Map of old occurrence IDs to new occurrence IDs + */ + private function cloneOccurrences(EventDomainObject $event, int $newEventId): array + { + $now = now()->toDateTimeString(); + $oldToNewOccurrenceMap = []; + + $event->getEventOccurrences() + ?->filter(fn (EventOccurrenceDomainObject $occurrence) => $occurrence->getStartDate() >= $now + && $occurrence->getStatus() !== EventOccurrenceStatus::CANCELLED->name + ) + ->each(function (EventOccurrenceDomainObject $occurrence) use ($newEventId, &$oldToNewOccurrenceMap) { + $newOccurrence = $this->eventOccurrenceRepository->create([ + 'event_id' => $newEventId, + 'start_date' => $occurrence->getStartDate(), + 'end_date' => $occurrence->getEndDate(), + 'status' => $occurrence->getStatus(), + 'capacity' => $occurrence->getCapacity(), + 'used_capacity' => 0, + 'label' => $occurrence->getLabel(), + 'is_overridden' => $occurrence->getIsOverridden(), + 'short_id' => IdHelper::shortId(IdHelper::OCCURRENCE_PREFIX), + ]); + $oldToNewOccurrenceMap[$occurrence->getId()] = $newOccurrence->getId(); + }); + + return $oldToNewOccurrenceMap; + } + + private function cloneOccurrenceProductSettings( + array $oldToNewOccurrenceMap, + array $oldProductToNewProductMap, + array $oldPriceToNewPriceMap, + ): void { + foreach ($oldToNewOccurrenceMap as $oldOccurrenceId => $newOccurrenceId) { + $priceOverrides = $this->priceOverrideRepository->findWhere([ + 'event_occurrence_id' => $oldOccurrenceId, + ]); + + foreach ($priceOverrides as $override) { + $newPriceId = $oldPriceToNewPriceMap[$override->getProductPriceId()] ?? null; + if ($newPriceId === null) { + continue; + } + + $this->priceOverrideRepository->create([ + 'event_occurrence_id' => $newOccurrenceId, + 'product_price_id' => $newPriceId, + 'price' => $override->getPrice(), + ]); + } + + $visibilityRecords = $this->visibilityRepository->findWhere([ + 'event_occurrence_id' => $oldOccurrenceId, + ]); + + foreach ($visibilityRecords as $visibility) { + $newProductId = $oldProductToNewProductMap[$visibility->getProductId()] ?? null; + if ($newProductId === null) { + continue; + } + + $this->visibilityRepository->create([ + 'event_occurrence_id' => $newOccurrenceId, + 'product_id' => $newProductId, + ]); + } + } + } } diff --git a/backend/app/Services/Domain/Event/EventOccurrenceGeneratorService.php b/backend/app/Services/Domain/Event/EventOccurrenceGeneratorService.php new file mode 100644 index 0000000000..fad6265c72 --- /dev/null +++ b/backend/app/Services/Domain/Event/EventOccurrenceGeneratorService.php @@ -0,0 +1,191 @@ +ruleParser->parse($recurrenceRule, $event->getTimezone() ?? 'UTC'); + + $existingOccurrences = $this->occurrenceRepository->findWhere([ + EventOccurrenceDomainObjectAbstract::EVENT_ID => $event->getId(), + ]); + + $existingByStartDate = collect($existingOccurrences)->keyBy( + fn (EventOccurrenceDomainObject $occ) => $occ->getStartDate() + ); + + $existingIds = collect($existingOccurrences) + ->map(fn (EventOccurrenceDomainObject $occ) => $occ->getId()) + ->all(); + // Anything attendee-bearing is "in use" for regeneration purposes, + // mirroring the single/bulk delete handlers which both block on + // attendees OR order_items. Normal checkout creates the two together, + // but attendees-without-order-items can exist (manual creation flows, + // imports, partial restores) and we must not silently soft-delete + // their occurrences just because no order_item row points at them. + $occurrenceIdsInUse = $this->getOccurrenceIdsInUse($existingIds); + + $result = collect(); + $matchedExistingIds = []; + + foreach ($candidates as $candidate) { + $startDateKey = $candidate['start']->toDateTimeString(); + + $existing = $existingByStartDate->get($startDateKey); + + if ($existing) { + $matchedExistingIds[] = $existing->getId(); + + if ($occurrenceIdsInUse->contains($existing->getId()) || $existing->getIsOverridden()) { + $result->push($existing); + + continue; + } + + $this->occurrenceRepository->updateWhere( + attributes: [ + EventOccurrenceDomainObjectAbstract::START_DATE => $candidate['start']->toDateTimeString(), + EventOccurrenceDomainObjectAbstract::END_DATE => $candidate['end']?->toDateTimeString(), + EventOccurrenceDomainObjectAbstract::CAPACITY => $candidate['capacity'], + EventOccurrenceDomainObjectAbstract::LABEL => $candidate['label'] ?? null, + ], + where: [EventOccurrenceDomainObjectAbstract::ID => $existing->getId()] + ); + + $updated = $this->occurrenceRepository->findById($existing->getId()); + $result->push($updated); + } else { + $newOccurrence = $this->occurrenceRepository->create([ + EventOccurrenceDomainObjectAbstract::EVENT_ID => $event->getId(), + EventOccurrenceDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::OCCURRENCE_PREFIX), + EventOccurrenceDomainObjectAbstract::START_DATE => $candidate['start']->toDateTimeString(), + EventOccurrenceDomainObjectAbstract::END_DATE => $candidate['end']?->toDateTimeString(), + EventOccurrenceDomainObjectAbstract::STATUS => EventOccurrenceStatus::ACTIVE->name, + EventOccurrenceDomainObjectAbstract::CAPACITY => $candidate['capacity'], + EventOccurrenceDomainObjectAbstract::USED_CAPACITY => 0, + EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => false, + EventOccurrenceDomainObjectAbstract::LABEL => $candidate['label'] ?? null, + ]); + + $result->push($newOccurrence); + } + } + + $this->removeStaleOccurrences($existingOccurrences, $matchedExistingIds, $occurrenceIdsInUse); + + return $result; + } + + private function removeStaleOccurrences( + Collection $existingOccurrences, + array $matchedExistingIds, + Collection $occurrenceIdsInUse, + ): void { + $idsToDelete = []; + $eventIdsToDelete = []; + + foreach ($existingOccurrences as $existing) { + if (in_array($existing->getId(), $matchedExistingIds, true)) { + continue; + } + + if ($existing->getIsOverridden()) { + continue; + } + + if ($occurrenceIdsInUse->contains($existing->getId())) { + $this->occurrenceRepository->updateWhere( + attributes: [ + EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => true, + ], + where: [EventOccurrenceDomainObjectAbstract::ID => $existing->getId()] + ); + + continue; + } + + if ($existing->getStatus() === EventOccurrenceStatus::CANCELLED->name) { + continue; + } + + $idsToDelete[] = $existing->getId(); + $eventIdsToDelete[$existing->getEventId()] = true; + } + + if ($idsToDelete === []) { + return; + } + + // Mirror the single/bulk delete handlers: cancel WAITING/OFFERED waitlist + // entries scoped to the soft-deleted occurrences first. The FK is + // nullOnDelete which only fires on hard deletes, so without this the + // entries are left pointing at soft-deleted rows and crash + // ProcessWaitlistService on the next CapacityChangedEvent. + $this->waitlistEntryRepository->updateWhere( + attributes: [ + 'status' => WaitlistEntryStatus::CANCELLED->name, + ], + where: [ + ['event_id', 'in', array_keys($eventIdsToDelete)], + ['event_occurrence_id', 'in', $idsToDelete], + ['status', 'in', [ + WaitlistEntryStatus::WAITING->name, + WaitlistEntryStatus::OFFERED->name, + ]], + ], + ); + + $this->occurrenceRepository->deleteWhere([ + [EventOccurrenceDomainObjectAbstract::ID, 'in', $idsToDelete], + ]); + } + + /** + * Returns the subset of given occurrence ids that have either an order_item + * or an attendee currently pointing at them. This is the "do not delete" + * set for regeneration. Mirrors the single/bulk delete handlers which both + * block on attendees OR order_items so the three paths agree on what + * counts as in-use. + */ + private function getOccurrenceIdsInUse(array $occurrenceIds): Collection + { + if (empty($occurrenceIds)) { + return collect(); + } + + $withOrderItems = DB::table('order_items') + ->whereIn('event_occurrence_id', $occurrenceIds) + ->whereNull('deleted_at') + ->distinct() + ->pluck('event_occurrence_id'); + + $withAttendees = DB::table('attendees') + ->whereIn('event_occurrence_id', $occurrenceIds) + ->whereNull('deleted_at') + ->distinct() + ->pluck('event_occurrence_id'); + + return $withOrderItems->merge($withAttendees)->unique()->values(); + } +} diff --git a/backend/app/Services/Domain/Event/EventStatsFetchService.php b/backend/app/Services/Domain/Event/EventStatsFetchService.php index 071fe836ed..518ebcf031 100644 --- a/backend/app/Services/Domain/Event/EventStatsFetchService.php +++ b/backend/app/Services/Domain/Event/EventStatsFetchService.php @@ -16,9 +16,7 @@ public function __construct( private DatabaseManager $db, private EventRepositoryInterface $eventRepository, - ) - { - } + ) {} public function getEventStats(EventStatsRequestDTO $requestData): EventStatsResponseDTO { @@ -32,28 +30,50 @@ public function getEventStats(EventStatsRequestDTO $requestData): EventStatsResp } $eventId = $requestData->event_id; + $occurrenceId = $requestData->occurrence_id; + + if ($occurrenceId !== null) { + // event_id is bound here so an organiser with access to event A + // cannot pass an occurrence id belonging to event B and read its + // stats. Action-level authorization gates eventId; this keeps the + // query honest about that scope. + $totalsQuery = <<<'SQL' + SELECT + COALESCE(SUM(eos.products_sold), 0) AS total_products_sold, + COALESCE(SUM(eos.orders_created), 0) AS total_orders, + COALESCE(SUM(eos.sales_total_gross), 0) AS total_gross_sales, + COALESCE(SUM(eos.total_tax), 0) AS total_tax, + COALESCE(SUM(eos.total_fee), 0) AS total_fees, + 0 AS total_views, + COALESCE(SUM(eos.total_refunded), 0) AS total_refunded, + COALESCE(SUM(eos.attendees_registered), 0) AS attendees_registered + FROM event_occurrence_statistics eos + WHERE eos.event_occurrence_id = :occurrenceId + AND eos.event_id = :eventId + AND eos.deleted_at IS NULL; + SQL; + $totalsResult = $this->db->selectOne($totalsQuery, [ + 'occurrenceId' => $occurrenceId, + 'eventId' => $eventId, + ]); + } else { + $totalsQuery = <<<'SQL' + SELECT + COALESCE(SUM(eos.products_sold), 0) AS total_products_sold, + COALESCE(SUM(eos.orders_created), 0) AS total_orders, + COALESCE(SUM(eos.sales_total_gross), 0) AS total_gross_sales, + COALESCE(SUM(eos.total_tax), 0) AS total_tax, + COALESCE(SUM(eos.total_fee), 0) AS total_fees, + COALESCE((SELECT SUM(es.total_views) FROM event_statistics es WHERE es.event_id = :eventIdViews AND es.deleted_at IS NULL), 0) AS total_views, + COALESCE(SUM(eos.total_refunded), 0) AS total_refunded, + COALESCE(SUM(eos.attendees_registered), 0) AS attendees_registered + FROM event_occurrence_statistics eos + WHERE eos.event_id = :eventId + AND eos.deleted_at IS NULL; + SQL; + $totalsResult = $this->db->selectOne($totalsQuery, ['eventId' => $eventId, 'eventIdViews' => $eventId]); + } - // Aggregate total statistics for the event for all time - $totalsQuery = <<db->selectOne($totalsQuery, ['eventId' => $eventId]); - - // Use the results to populate the response DTO return new EventStatsResponseDTO( daily_stats: $this->getDailyEventStats($requestData), start_date: $requestData->start_date, @@ -72,10 +92,20 @@ public function getEventStats(EventStatsRequestDTO $requestData): EventStatsResp public function getDailyEventStats(EventStatsRequestDTO $requestData): Collection { $eventId = $requestData->event_id; - + $occurrenceId = $requestData->occurrence_id; $startDate = $requestData->start_date; $endDate = $requestData->end_date; + if ($occurrenceId !== null) { + // event_id is bound alongside occurrence_id so cross-event ids + // produce zero rows rather than another event's stats. + $whereClause = 'eods.event_occurrence_id = :occurrenceId AND eods.event_id = :eventId'; + $bindings = ['startDate' => $startDate, 'endDate' => $endDate, 'occurrenceId' => $occurrenceId, 'eventId' => $eventId]; + } else { + $whereClause = 'eods.event_id = :eventId'; + $bindings = ['startDate' => $startDate, 'endDate' => $endDate, 'eventId' => $eventId]; + } + $query = <<db->select($query, [ - 'startDate' => $startDate, - 'endDate' => $endDate, - 'eventId' => $eventId, - ]); + $results = $this->db->select($query, $bindings); $currentTime = Carbon::now('UTC')->toTimeString(); return collect($results)->map(function (object $result) use ($currentTime) { - $dateTimeWithCurrentTime = (new Carbon($result->date))->setTimezone('UTC')->format('Y-m-d') . ' ' . $currentTime; + $dateTimeWithCurrentTime = (new Carbon($result->date))->setTimezone('UTC')->format('Y-m-d').' '.$currentTime; return new EventDailyStatsResponseDTO( date: $dateTimeWithCurrentTime, @@ -156,7 +182,7 @@ private function resolveStatsDateRange(int $eventId, string $preset): array $endCandidates = array_filter([ $eventEnd, $bounds?->max_date ? Carbon::parse($bounds->max_date) : null, - (!$eventEnd || $eventEnd->isFuture()) ? Carbon::now() : null, + (! $eventEnd || $eventEnd->isFuture()) ? Carbon::now() : null, ]); $endDate = $endCandidates ? max($endCandidates) : Carbon::now(); break; @@ -171,20 +197,29 @@ private function resolveStatsDateRange(int $eventId, string $preset): array ]; } - public function getCheckedInStats(int $eventId): EventCheckInStatsResponseDTO + public function getCheckedInStats(int $eventId, ?int $occurrenceId = null): EventCheckInStatsResponseDTO { + $bindings = ['eventId' => $eventId]; + + $occurrenceFilter = ''; + if ($occurrenceId !== null) { + $occurrenceFilter = 'AND attendees.event_occurrence_id = :occurrenceId'; + $bindings['occurrenceId'] = $occurrenceId; + } + $query = <<db->select($query)[0]; + $result = $this->db->select($query, $bindings)[0]; return new EventCheckInStatsResponseDTO( total_checked_in_attendees: $result->checked_in_count ?? 0, diff --git a/backend/app/Services/Domain/Event/RecurrenceRuleExclusionService.php b/backend/app/Services/Domain/Event/RecurrenceRuleExclusionService.php new file mode 100644 index 0000000000..21f3c6fb30 --- /dev/null +++ b/backend/app/Services/Domain/Event/RecurrenceRuleExclusionService.php @@ -0,0 +1,112 @@ + $startDates UTC datetime strings of cancelled occurrences + */ + public function addExclusions(int $eventId, array $startDates): void + { + $event = $this->eventRepository->findByIdLocked($eventId); + + if ($event->getType() !== EventType::RECURRING->name) { + return; + } + + $rule = $this->extractRule($event); + $excluded = $rule['excluded_occurrences'] ?? []; + $changed = false; + + foreach (array_unique($startDates) as $startDate) { + $datetime = $this->formatExclusion($startDate, $event->getTimezone() ?? 'UTC'); + + if (! in_array($datetime, $excluded, true)) { + $excluded[] = $datetime; + $changed = true; + } + } + + if (! $changed) { + return; + } + + $rule['excluded_occurrences'] = $excluded; + + $this->eventRepository->updateFromArray( + id: $eventId, + attributes: [ + EventDomainObjectAbstract::RECURRENCE_RULE => $rule, + ], + ); + } + + public function removeExclusion(int $eventId, string $startDate): void + { + $event = $this->eventRepository->findByIdLocked($eventId); + + if ($event->getType() !== EventType::RECURRING->name) { + return; + } + + $rule = $this->extractRule($event); + $datetime = $this->formatExclusion($startDate, $event->getTimezone() ?? 'UTC'); + $legacyDate = substr($datetime, 0, 10); + + $excludedOccurrences = $rule['excluded_occurrences'] ?? []; + $excludedDates = $rule['excluded_dates'] ?? []; + + if (! in_array($datetime, $excludedOccurrences, true) + && ! in_array($legacyDate, $excludedDates, true) + ) { + return; + } + + $rule['excluded_occurrences'] = array_values(array_filter( + $excludedOccurrences, + static fn (string $dt) => $dt !== $datetime, + )); + $rule['excluded_dates'] = array_values(array_filter( + $excludedDates, + static fn (string $d) => $d !== $legacyDate, + )); + + $this->eventRepository->updateFromArray( + id: $eventId, + attributes: [ + EventDomainObjectAbstract::RECURRENCE_RULE => $rule, + ], + ); + } + + private function extractRule(EventDomainObject $event): array + { + $rule = $event->getRecurrenceRule() ?? []; + + if (is_string($rule)) { + $rule = json_decode($rule, true, 512, JSON_THROW_ON_ERROR); + } + + return $rule; + } + + private function formatExclusion(string $startDate, string $timezone): string + { + return CarbonImmutable::parse($startDate, 'UTC') + ->setTimezone($timezone) + ->format('Y-m-d H:i'); + } +} diff --git a/backend/app/Services/Domain/Event/RecurrenceRuleParserService.php b/backend/app/Services/Domain/Event/RecurrenceRuleParserService.php new file mode 100644 index 0000000000..29a377f0e7 --- /dev/null +++ b/backend/app/Services/Domain/Event/RecurrenceRuleParserService.php @@ -0,0 +1,456 @@ + + */ + public function parse(array $rule, string $timezone): Collection + { + $candidates = collect(); + + if (! isset($rule['frequency'])) { + throw new InvalidRecurrenceRuleException(__('Recurrence rule must include a frequency')); + } + + $frequency = $rule['frequency']; + $interval = $rule['interval'] ?? 1; + $rawTimes = $rule['times_of_day'] ?? ['00:00']; + $fallbackDuration = $rule['duration_minutes'] ?? null; + $defaultCapacity = $rule['default_capacity'] ?? null; + $excludedDates = collect($rule['excluded_dates'] ?? []); + $excludedOccurrences = collect($rule['excluded_occurrences'] ?? []); + $additionalDates = collect($rule['additional_dates'] ?? []); + + $timeSlots = $this->normalizeTimeSlots($rawTimes, $fallbackDuration); + + $rangeType = $rule['range']['type'] ?? 'count'; + // For `until` ranges we ask the generator for one more date than the + // hard cap so the overflow check below (`> MAX_OCCURRENCES`) can + // actually fire on a single-timeslot rule that would otherwise stop + // exactly at MAX. Without this, a daily/until rule that resolves to + // 1500 dates silently truncates to 1200 and the handler's overflow + // guard never sees a value > MAX. For `count` ranges the user has + // explicitly named a number — generate exactly that many. + $maxCount = $rangeType === 'count' + ? ($rule['range']['count'] ?? 10) + : self::MAX_OCCURRENCES + 1; + $untilDate = $rangeType === 'until' + ? CarbonImmutable::parse($rule['range']['until'], $timezone)->endOfDay() + : null; + + $dates = $this->generateDates($rule, $frequency, $interval, $timezone, $maxCount, $untilDate); + + foreach ($dates as $date) { + foreach ($timeSlots as $slot) { + // Allow one candidate beyond MAX so the caller's `> MAX` overflow + // check can fire — without this the parser silently truncated + // to exactly MAX and the validation in the handler was dead. + if ($candidates->count() > self::MAX_OCCURRENCES) { + break 2; + } + + $parts = explode(':', $slot['time']); + $start = $date->setTime((int) $parts[0], (int) $parts[1], 0); + if ($this->isExcluded($start, $excludedDates, $excludedOccurrences)) { + continue; + } + + $duration = $slot['duration_minutes']; + $end = $duration ? $start->addMinutes($duration) : null; + + $startUtc = $start->setTimezone('UTC'); + $endUtc = $end ? $end->setTimezone('UTC') : null; + + $candidates->push([ + 'start' => $startUtc, + 'end' => $endUtc, + 'capacity' => $defaultCapacity, + 'label' => $slot['label'], + ]); + } + } + + foreach ($additionalDates as $additional) { + if ($candidates->count() > self::MAX_OCCURRENCES) { + break; + } + + if (! is_array($additional) || ! isset($additional['date'])) { + throw new InvalidRecurrenceRuleException(__('Additional recurrence dates must include a date')); + } + + $addDate = CarbonImmutable::parse($additional['date'], $timezone); + $additionalTime = $additional['time'] ?? '00:00'; + if (! preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $additionalTime)) { + throw new InvalidRecurrenceRuleException( + __('Recurrence additional_dates time must be in HH:MM 24-hour format') + ); + } + $parts = explode(':', $additionalTime); + $start = $addDate->setTime((int) $parts[0], (int) $parts[1], 0); + $end = $fallbackDuration ? $start->addMinutes($fallbackDuration) : null; + + $startUtc = $start->setTimezone('UTC'); + $endUtc = $end ? $end->setTimezone('UTC') : null; + + $candidates->push([ + 'start' => $startUtc, + 'end' => $endUtc, + 'capacity' => $defaultCapacity, + 'label' => null, + ]); + } + + return $candidates + ->sortBy(fn (array $candidate) => $candidate['start']->getTimestamp()) + ->unique(fn (array $candidate) => $candidate['start']->toDateTimeString()) + ->values(); + } + + /** + * @return array + */ + private function normalizeTimeSlots(array $rawTimes, ?int $fallbackDuration): array + { + return array_map(function ($entry) use ($fallbackDuration) { + $time = is_string($entry) ? $entry : ($entry['time'] ?? null); + + if (! is_string($time) || ! preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $time)) { + throw new InvalidRecurrenceRuleException( + __('Recurrence times_of_day entries must be in HH:MM 24-hour format') + ); + } + + if (is_string($entry)) { + return [ + 'time' => $entry, + 'label' => null, + 'duration_minutes' => $fallbackDuration, + ]; + } + + if (! is_array($entry) || ! isset($entry['time'])) { + throw new InvalidRecurrenceRuleException(__('Recurrence time slots must include a time')); + } + + return [ + 'time' => $entry['time'], + 'label' => $entry['label'] ?? null, + 'duration_minutes' => $entry['duration_minutes'] ?? $fallbackDuration, + ]; + }, $rawTimes); + } + + private function generateDates( + array $rule, + string $frequency, + int $interval, + string $timezone, + int $maxCount, + ?CarbonImmutable $untilDate, + ): Collection { + return match ($frequency) { + 'daily' => $this->generateDailyDates($rule, $interval, $timezone, $maxCount, $untilDate), + 'weekly' => $this->generateWeeklyDates($rule, $interval, $timezone, $maxCount, $untilDate), + 'monthly' => $this->generateMonthlyDates($rule, $interval, $timezone, $maxCount, $untilDate), + 'yearly' => $this->generateYearlyDates($rule, $interval, $timezone, $maxCount, $untilDate), + default => throw new InvalidRecurrenceRuleException(__('Unsupported recurrence frequency')), + }; + } + + private function isExcluded(CarbonImmutable $start, Collection $excludedDates, Collection $excludedOccurrences): bool + { + return $excludedDates->contains($start->format('Y-m-d')) + || $excludedOccurrences->contains($start->format('Y-m-d H:i')); + } + + private function generateDailyDates( + array $rule, + int $interval, + string $timezone, + int $maxCount, + ?CarbonImmutable $untilDate, + ): Collection { + $dates = collect(); + $startDate = $this->getStartDate($rule, $timezone); + $current = $startDate; + + while ($dates->count() < $maxCount) { + if ($untilDate && $current->greaterThan($untilDate)) { + break; + } + + $dates->push($current); + $current = $current->addDays($interval); + } + + return $dates; + } + + private function generateWeeklyDates( + array $rule, + int $interval, + string $timezone, + int $maxCount, + ?CarbonImmutable $untilDate, + ): Collection { + $dates = collect(); + $daysOfWeek = $rule['days_of_week'] ?? []; + $startDate = $this->getStartDate($rule, $timezone); + $current = $startDate->startOfWeek(Carbon::MONDAY); + + $dayMap = [ + 'monday' => Carbon::MONDAY, + 'tuesday' => Carbon::TUESDAY, + 'wednesday' => Carbon::WEDNESDAY, + 'thursday' => Carbon::THURSDAY, + 'friday' => Carbon::FRIDAY, + 'saturday' => Carbon::SATURDAY, + 'sunday' => Carbon::SUNDAY, + ]; + + // Bare ->filter() drops "falsy" values, which includes Carbon::SUNDAY (= 0), + // so a weekly Sunday rule was silently producing zero dates. Compare to + // null explicitly to keep Sunday in the set. + $dayNumbers = collect($daysOfWeek) + ->map(fn (string $day) => $dayMap[strtolower($day)] ?? null) + ->filter(fn (?int $dayNumber) => $dayNumber !== null) + ->sort() + ->values(); + + if ($dayNumbers->isEmpty()) { + return $dates; + } + + while ($dates->count() < $maxCount) { + foreach ($dayNumbers as $dayNumber) { + $daysFromMonday = $dayNumber - CarbonInterface::MONDAY; + if ($daysFromMonday < 0) { + $daysFromMonday += 7; + } + $candidate = $current->addDays($daysFromMonday); + + if ($candidate->lessThan($startDate)) { + continue; + } + + if ($untilDate && $candidate->greaterThan($untilDate)) { + return $dates; + } + + $dates->push($candidate); + + if ($dates->count() >= $maxCount) { + return $dates; + } + } + + $current = $current->addWeeks($interval); + } + + return $dates; + } + + private function generateMonthlyDates( + array $rule, + int $interval, + string $timezone, + int $maxCount, + ?CarbonImmutable $untilDate, + ): Collection { + $pattern = $rule['monthly_pattern'] ?? 'by_day_of_month'; + + return match ($pattern) { + 'by_day_of_month' => $this->generateMonthlyByDayOfMonth($rule, $interval, $timezone, $maxCount, $untilDate), + 'by_day_of_week' => $this->generateMonthlyByDayOfWeek($rule, $interval, $timezone, $maxCount, $untilDate), + default => collect(), + }; + } + + private function generateMonthlyByDayOfMonth( + array $rule, + int $interval, + string $timezone, + int $maxCount, + ?CarbonImmutable $untilDate, + ): Collection { + $dates = collect(); + $daysOfMonth = $rule['days_of_month'] ?? [1]; + $startDate = $this->getStartDate($rule, $timezone); + $current = $startDate->startOfMonth(); + $safetyLimit = $maxCount * 4; + $iterations = 0; + + while ($dates->count() < $maxCount && $iterations < $safetyLimit) { + $iterations++; + + foreach ($daysOfMonth as $day) { + $daysInMonth = $current->daysInMonth; + if ($day > $daysInMonth) { + continue; + } + + $candidate = $current->setDay($day); + + if ($candidate->lessThan($startDate)) { + continue; + } + + if ($untilDate && $candidate->greaterThan($untilDate)) { + return $dates; + } + + $dates->push($candidate); + + if ($dates->count() >= $maxCount) { + return $dates; + } + } + + $current = $current->addMonths($interval); + } + + return $dates; + } + + private function generateMonthlyByDayOfWeek( + array $rule, + int $interval, + string $timezone, + int $maxCount, + ?CarbonImmutable $untilDate, + ): Collection { + $dates = collect(); + $dayOfWeek = $rule['day_of_week'] ?? 'monday'; + $weekPosition = $rule['week_position'] ?? 1; + $startDate = $this->getStartDate($rule, $timezone); + $current = $startDate->startOfMonth(); + + $dayMap = [ + 'monday' => Carbon::MONDAY, + 'tuesday' => Carbon::TUESDAY, + 'wednesday' => Carbon::WEDNESDAY, + 'thursday' => Carbon::THURSDAY, + 'friday' => Carbon::FRIDAY, + 'saturday' => Carbon::SATURDAY, + 'sunday' => Carbon::SUNDAY, + ]; + + $carbonDay = $dayMap[strtolower($dayOfWeek)] ?? Carbon::MONDAY; + $safetyLimit = $maxCount * 4; + $iterations = 0; + + while ($dates->count() < $maxCount && $iterations < $safetyLimit) { + $iterations++; + $candidate = $this->getNthDayOfWeekInMonth($current, $carbonDay, $weekPosition); + + if ($candidate !== null && $candidate->greaterThanOrEqualTo($startDate)) { + if ($untilDate && $candidate->greaterThan($untilDate)) { + return $dates; + } + + $dates->push($candidate); + + if ($dates->count() >= $maxCount) { + return $dates; + } + } + + $current = $current->addMonths($interval); + } + + return $dates; + } + + private function getNthDayOfWeekInMonth( + CarbonImmutable $monthStart, + int $carbonDay, + int $weekPosition, + ): ?CarbonImmutable { + $firstOfMonth = $monthStart->startOfMonth(); + + // The day-of-week constants on Carbon (e.g. Carbon::SUNDAY = 0) follow + // PHP's date('w') convention, NOT ISO-8601. dayOfWeekIso returns 1..7 + // (Sun = 7), which never equals 0 — that mismatch turned monthly nth/last + // Sunday rules into infinite loops walking days that never matched. + // dayOfWeek returns 0..6 and lines up with the Carbon::SUNDAY constant. + if ($weekPosition === -1) { + $lastOfMonth = $firstOfMonth->endOfMonth(); + $candidate = $lastOfMonth; + while ($candidate->dayOfWeek !== $carbonDay) { + $candidate = $candidate->subDay(); + } + + return $candidate->startOfDay(); + } + + $candidate = $firstOfMonth; + while ($candidate->dayOfWeek !== $carbonDay) { + $candidate = $candidate->addDay(); + } + + $candidate = $candidate->addWeeks($weekPosition - 1); + + if ($candidate->month !== $firstOfMonth->month) { + return null; + } + + return $candidate->startOfDay(); + } + + private function generateYearlyDates( + array $rule, + int $interval, + string $timezone, + int $maxCount, + ?CarbonImmutable $untilDate, + ): Collection { + $dates = collect(); + $startDate = $this->getStartDate($rule, $timezone); + $month = $rule['month'] ?? $startDate->month; + $dayOfMonth = ($rule['days_of_month'] ?? [$startDate->day])[0] ?? $startDate->day; + + $current = $startDate->startOfYear()->month($month); + $daysInMonth = $current->daysInMonth; + $current = $current->day(min($dayOfMonth, $daysInMonth)); + + if ($current->lessThan($startDate)) { + $current = $current->addYears($interval); + } + + while ($dates->count() < $maxCount) { + if ($untilDate && $current->greaterThan($untilDate)) { + break; + } + + $dates->push($current); + $nextYear = $current->addYears($interval); + $daysInTargetMonth = $nextYear->month($month)->daysInMonth; + $current = $nextYear->month($month)->day(min($dayOfMonth, $daysInTargetMonth)); + } + + return $dates; + } + + private function getStartDate(array $rule, string $timezone): CarbonImmutable + { + if (isset($rule['range']['start'])) { + return CarbonImmutable::parse($rule['range']['start'], $timezone)->startOfDay(); + } + + return CarbonImmutable::now($timezone)->startOfDay(); + } +} diff --git a/backend/app/Services/Domain/EventOccurrence/CancelOccurrenceAttendeesService.php b/backend/app/Services/Domain/EventOccurrence/CancelOccurrenceAttendeesService.php new file mode 100644 index 0000000000..a570069ab0 --- /dev/null +++ b/backend/app/Services/Domain/EventOccurrence/CancelOccurrenceAttendeesService.php @@ -0,0 +1,171 @@ +name, AttendeeStatus::AWAITING_PAYMENT->name]; + + $attendees = $this->attendeeRepository->findWhere([ + AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrenceId, + [AttendeeDomainObjectAbstract::STATUS, 'in', $statusesToCancel], + ]); + + if ($attendees->isEmpty()) { + return; + } + + $this->attendeeRepository->updateWhere( + attributes: [AttendeeDomainObjectAbstract::STATUS => AttendeeStatus::CANCELLED->name], + where: [ + AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrenceId, + [AttendeeDomainObjectAbstract::STATUS, 'in', $statusesToCancel], + ], + ); + + $soldCountsByProductPrice = $attendees + ->map(fn (AttendeeDomainObject $attendee) => $attendee->getProductPriceId()) + ->countBy(); + + foreach ($soldCountsByProductPrice as $productPriceId => $count) { + $this->productQuantityService->decreaseQuantitySold( + priceId: (int) $productPriceId, + adjustment: $count, + eventOccurrenceId: $occurrenceId, + ); + } + + // Mirror PartialEditAttendeeHandler's per-attendee stats decrement so + // attendees_registered tracks reality after a bulk occurrence cancel. + // Without this, the later refund flow's order-level decrement looks at + // attendees that are already CANCELLED, finds zero "active" rows, and + // decrements by zero — leaving attendees_registered inflated. Grouped + // by order to amortise the per-order date lookup and version bumps. + $this->decrementStatisticsForCancelledAttendees($eventId, $occurrenceId, $attendees); + + foreach ($attendees as $attendee) { + $this->domainEventDispatcherService->dispatch(new AttendeeEvent( + type: DomainEventType::ATTENDEE_CANCELLED, + attendeeId: $attendee->getId(), + )); + } + + $productIds = $attendees + ->map(fn (AttendeeDomainObject $attendee) => $attendee->getProductId()) + ->unique() + ->values() + ->all(); + + foreach ($productIds as $productId) { + event(new CapacityChangedEvent( + eventId: $eventId, + direction: CapacityChangeDirection::INCREASED, + productId: $productId, + eventOccurrenceId: $occurrenceId, + )); + } + } + + /** + * Calls EventStatisticsCancellationService::decrementForCancelledAttendee + * once per source order, summing attendees in that order tied to the + * cancelled occurrence. The service needs the order's created_at to find + * the daily-statistics row to decrement. + * + * Intentionally swallows version-mismatch / not-found errors at this + * boundary: the attendees are already cancelled and inventory adjusted, + * and a stats discrepancy is recoverable through reconciliation but a + * raised exception here would roll the cancel transaction back. + * + * @param \Illuminate\Support\Collection $attendees + */ + private function decrementStatisticsForCancelledAttendees( + int $eventId, + int $occurrenceId, + \Illuminate\Support\Collection $attendees, + ): void { + $countsByOrderId = $attendees + ->groupBy(fn (AttendeeDomainObject $attendee) => $attendee->getOrderId()) + ->map->count(); + + $orderIds = $countsByOrderId->keys()->all(); + if (empty($orderIds)) { + return; + } + + $orders = $this->orderRepository->findWhereIn('id', $orderIds) + ->keyBy(fn (OrderDomainObject $order) => $order->getId()); + + foreach ($countsByOrderId as $orderId => $attendeeCount) { + $order = $orders->get((int) $orderId); + if ($order === null) { + continue; + } + + try { + $this->statisticsCancellationService->decrementForCancelledAttendee( + eventId: $eventId, + orderDate: $order->getCreatedAt(), + attendeeCount: $attendeeCount, + occurrenceId: $occurrenceId, + ); + } catch (Throwable $e) { + $this->logger->error( + 'Failed to decrement attendee statistics during occurrence cancellation', + [ + 'event_id' => $eventId, + 'occurrence_id' => $occurrenceId, + 'order_id' => $orderId, + 'attendee_count' => $attendeeCount, + 'exception' => $e::class, + 'message' => $e->getMessage(), + ], + ); + } + } + } +} diff --git a/backend/app/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityService.php b/backend/app/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityService.php new file mode 100644 index 0000000000..03c37c28d0 --- /dev/null +++ b/backend/app/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityService.php @@ -0,0 +1,140 @@ +occurrenceRepository->findFirstWhere([ + 'id' => $occurrenceId, + 'event_id' => $eventId, + ]); + + if ($occurrence === null) { + throw ValidationException::withMessages([ + 'event_occurrence_id' => __('The specified event occurrence was not found'), + ]); + } + + if ($occurrence->isCancelled()) { + throw ValidationException::withMessages([ + 'event_occurrence_id' => __('This event occurrence has been cancelled'), + ]); + } + + // Past dates are blocked even when capacity is overridden — selling or + // manually issuing tickets for a session that has already ended is + // never the intended behaviour, and the public payload already filters + // these out so any request reaching here is stale or hand-crafted. + if ($occurrence->isPast()) { + throw ValidationException::withMessages([ + 'event_occurrence_id' => __('This event occurrence has already ended'), + ]); + } + + // SOLD_OUT is a capacity-derived status (ProductQuantityUpdateService + // flips it whenever used_capacity >= capacity), so blocking it here + // before the capacity-override branch would defeat the override flag in + // the most common case it was added for. Treat SOLD_OUT as a normal + // capacity gate that the override can bypass. + if (! $overrideCapacity && $occurrence->isSoldOut()) { + throw ValidationException::withMessages([ + 'event_occurrence_id' => __('This event occurrence is sold out'), + ]); + } + + if (! $overrideCapacity && $occurrence->getCapacity() !== null) { + $reservedForOccurrence = $this->orderItemRepository + ->getReservedQuantityForOccurrence($occurrenceId); + + $available = $occurrence->getCapacity() - $occurrence->getUsedCapacity() - $reservedForOccurrence; + if ($additionalQuantity > $available) { + throw ValidationException::withMessages([ + 'event_occurrence_id' => __('Not enough capacity available for this occurrence'), + ]); + } + } + + return $occurrence; + } + + /** + * Verifies the product is visible on the occurrence. Visibility rules are an + * allow-list — an occurrence with no rules is treated as "all products + * visible" (the default), matching `ProductFilterService::filterByOccurrenceVisibility` + * so the validator and the storefront filter agree. + * + * @param int[] $productIds + * + * @throws ValidationException + */ + public function assertProductsVisibleOnOccurrence(int $occurrenceId, array $productIds): void + { + if ($productIds === []) { + return; + } + + $rules = $this->productOccurrenceVisibilityRepository + ->findWhereIn('event_occurrence_id', [$occurrenceId]); + + if ($rules->isEmpty()) { + return; + } + + $visibleProductIds = $rules + ->map(fn ($rule) => $rule->getProductId()) + ->all(); + + foreach ($productIds as $productId) { + if (! in_array($productId, $visibleProductIds, true)) { + throw ValidationException::withMessages([ + 'event_occurrence_id' => __('One or more selected products are not available for this occurrence'), + ]); + } + } + } +} diff --git a/backend/app/Services/Domain/EventStatistics/EventStatisticsCancellationService.php b/backend/app/Services/Domain/EventStatistics/EventStatisticsCancellationService.php index c0e84410a0..2f2b72dcde 100644 --- a/backend/app/Services/Domain/EventStatistics/EventStatisticsCancellationService.php +++ b/backend/app/Services/Domain/EventStatistics/EventStatisticsCancellationService.php @@ -11,6 +11,8 @@ use HiEvents\Exceptions\EventStatisticsVersionMismatchException; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceDailyStatisticRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Services\Infrastructure\Utlitiy\Retry\Retrier; @@ -24,8 +26,10 @@ class EventStatisticsCancellationService { public function __construct( private readonly EventStatisticRepositoryInterface $eventStatisticsRepository, - private readonly EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository, - private readonly AttendeeRepositoryInterface $attendeeRepository, + private readonly EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository, + private readonly EventOccurrenceStatisticRepositoryInterface $eventOccurrenceStatisticRepository, + private readonly EventOccurrenceDailyStatisticRepositoryInterface $eventOccurrenceDailyStatisticRepository, + private readonly AttendeeRepositoryInterface $attendeeRepository, private readonly OrderRepositoryInterface $orderRepository, private readonly LoggerInterface $logger, private readonly DatabaseManager $databaseManager, @@ -84,6 +88,10 @@ public function decrementForCancelledOrder(OrderDomainObject $order): void // Decrement daily statistics $this->decrementDailyStatistics($order, $counts, $attempt); + // Decrement occurrence statistics + $this->decrementOccurrenceStatistics($order); + $this->decrementOccurrenceDailyStatistics($order); + // Mark statistics as decremented $this->markStatisticsAsDecremented($order); }); @@ -110,16 +118,17 @@ public function decrementForCancelledOrder(OrderDomainObject $order): void * @throws EventStatisticsVersionMismatchException * @throws Throwable */ - public function decrementForCancelledAttendee(int $eventId, string $orderDate, int $attendeeCount = 1): void + public function decrementForCancelledAttendee(int $eventId, string $orderDate, int $attendeeCount = 1, ?int $occurrenceId = null): void { $this->retrier->retry( - callableAction: function () use ($eventId, $orderDate, $attendeeCount): void { - $this->databaseManager->transaction(function () use ($eventId, $orderDate, $attendeeCount): void { - // Decrement aggregate statistics + callableAction: function () use ($eventId, $orderDate, $attendeeCount, $occurrenceId): void { + $this->databaseManager->transaction(function () use ($eventId, $orderDate, $attendeeCount, $occurrenceId): void { $this->decrementAggregateAttendeeStatistics($eventId, $attendeeCount); - - // Decrement daily statistics $this->decrementDailyAttendeeStatistics($eventId, $orderDate, $attendeeCount); + if ($occurrenceId !== null) { + $this->decrementOccurrenceAttendeeStatistics($occurrenceId, $attendeeCount); + $this->decrementOccurrenceDailyAttendeeStatistics($occurrenceId, $orderDate, $attendeeCount); + } }); }, onFailure: function (int $attempt, Throwable $e) use ($eventId, $orderDate, $attendeeCount): void { @@ -393,6 +402,192 @@ private function decrementDailyAttendeeStatistics(int $eventId, string $orderDat ); } + /** + * @throws EventStatisticsVersionMismatchException + */ + private function decrementOccurrenceAttendeeStatistics(int $occurrenceId, int $attendeeCount): void + { + $existing = $this->eventOccurrenceStatisticRepository->findFirstWhere([ + 'event_occurrence_id' => $occurrenceId, + ]); + + if (!$existing) { + return; + } + + $updates = [ + 'attendees_registered' => max(0, $existing->getAttendeesRegistered() - $attendeeCount), + 'version' => $existing->getVersion() + 1, + ]; + + $updated = $this->eventOccurrenceStatisticRepository->updateWhere( + attributes: $updates, + where: [ + 'event_occurrence_id' => $occurrenceId, + 'version' => $existing->getVersion(), + ] + ); + + if ($updated === 0) { + throw new EventStatisticsVersionMismatchException( + 'Occurrence statistics version mismatch for occurrence ' . $occurrenceId + ); + } + } + + /** + * @throws EventStatisticsVersionMismatchException + */ + private function decrementOccurrenceStatistics(OrderDomainObject $order): void + { + $itemsByOccurrence = []; + foreach ($order->getOrderItems() as $orderItem) { + $occId = $orderItem->getEventOccurrenceId(); + if ($occId === null) { + continue; + } + $itemsByOccurrence[$occId][] = $orderItem; + } + + foreach ($itemsByOccurrence as $occurrenceId => $items) { + $existing = $this->eventOccurrenceStatisticRepository->findFirstWhere([ + 'event_occurrence_id' => $occurrenceId, + ]); + + if (!$existing) { + continue; + } + + $productsSold = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getQuantity(), $items)); + $attendeesRegistered = $this->countActiveAttendeesForOccurrence($order->getId(), $occurrenceId); + + $updates = [ + 'attendees_registered' => max(0, $existing->getAttendeesRegistered() - $attendeesRegistered), + 'products_sold' => max(0, $existing->getProductsSold() - $productsSold), + 'orders_created' => max(0, $existing->getOrdersCreated() - 1), + 'orders_cancelled' => ($existing->getOrdersCancelled() ?? 0) + 1, + 'version' => $existing->getVersion() + 1, + ]; + + $updated = $this->eventOccurrenceStatisticRepository->updateWhere( + attributes: $updates, + where: [ + 'event_occurrence_id' => $occurrenceId, + 'version' => $existing->getVersion(), + ] + ); + + if ($updated === 0) { + throw new EventStatisticsVersionMismatchException( + 'Occurrence statistics version mismatch for occurrence ' . $occurrenceId + ); + } + } + } + + private function countActiveAttendeesForOccurrence(int $orderId, int $occurrenceId): int + { + return $this->attendeeRepository->findWhereIn( + field: 'status', + values: [AttendeeStatus::ACTIVE->name, AttendeeStatus::AWAITING_PAYMENT->name], + additionalWhere: [ + 'order_id' => $orderId, + 'event_occurrence_id' => $occurrenceId, + ], + )->count(); + } + + /** + * @throws EventStatisticsVersionMismatchException + */ + private function decrementOccurrenceDailyStatistics(OrderDomainObject $order): void + { + $orderDate = (new Carbon($order->getCreatedAt()))->format('Y-m-d'); + + $itemsByOccurrence = []; + foreach ($order->getOrderItems() as $orderItem) { + $occId = $orderItem->getEventOccurrenceId(); + if ($occId === null) { + continue; + } + $itemsByOccurrence[$occId][] = $orderItem; + } + + foreach ($itemsByOccurrence as $occurrenceId => $items) { + $existing = $this->eventOccurrenceDailyStatisticRepository->findFirstWhere([ + 'event_occurrence_id' => $occurrenceId, + 'date' => $orderDate, + ]); + + if (!$existing) { + continue; + } + + $productsSold = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getQuantity(), $items)); + $attendeesRegistered = $this->countActiveAttendeesForOccurrence($order->getId(), $occurrenceId); + + $updates = [ + 'attendees_registered' => max(0, $existing->getAttendeesRegistered() - $attendeesRegistered), + 'products_sold' => max(0, $existing->getProductsSold() - $productsSold), + 'orders_created' => max(0, $existing->getOrdersCreated() - 1), + 'orders_cancelled' => ($existing->getOrdersCancelled() ?? 0) + 1, + 'version' => $existing->getVersion() + 1, + ]; + + $updated = $this->eventOccurrenceDailyStatisticRepository->updateWhere( + attributes: $updates, + where: [ + 'event_occurrence_id' => $occurrenceId, + 'date' => $orderDate, + 'version' => $existing->getVersion(), + ] + ); + + if ($updated === 0) { + throw new EventStatisticsVersionMismatchException( + 'Occurrence daily statistics version mismatch for occurrence ' . $occurrenceId + ); + } + } + } + + /** + * @throws EventStatisticsVersionMismatchException + */ + private function decrementOccurrenceDailyAttendeeStatistics(int $occurrenceId, string $orderDate, int $attendeeCount): void + { + $formattedDate = (new Carbon($orderDate))->format('Y-m-d'); + + $existing = $this->eventOccurrenceDailyStatisticRepository->findFirstWhere([ + 'event_occurrence_id' => $occurrenceId, + 'date' => $formattedDate, + ]); + + if (!$existing) { + return; + } + + $updates = [ + 'attendees_registered' => max(0, $existing->getAttendeesRegistered() - $attendeeCount), + 'version' => $existing->getVersion() + 1, + ]; + + $updated = $this->eventOccurrenceDailyStatisticRepository->updateWhere( + attributes: $updates, + where: [ + 'event_occurrence_id' => $occurrenceId, + 'date' => $formattedDate, + 'version' => $existing->getVersion(), + ] + ); + + if ($updated === 0) { + throw new EventStatisticsVersionMismatchException( + 'Occurrence daily statistics version mismatch for occurrence ' . $occurrenceId + ); + } + } + /** * Mark that statistics have been decremented for this order */ diff --git a/backend/app/Services/Domain/EventStatistics/EventStatisticsIncrementService.php b/backend/app/Services/Domain/EventStatistics/EventStatisticsIncrementService.php index d6b7a1b826..e5930ba000 100644 --- a/backend/app/Services/Domain/EventStatistics/EventStatisticsIncrementService.php +++ b/backend/app/Services/Domain/EventStatistics/EventStatisticsIncrementService.php @@ -4,12 +4,15 @@ namespace HiEvents\Services\Domain\EventStatistics; +use HiEvents\DomainObjects\Enums\ProductType; use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract; use HiEvents\DomainObjects\Generated\PromoCodeDomainObjectAbstract; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\Exceptions\EventStatisticsVersionMismatchException; use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceDailyStatisticRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; @@ -26,8 +29,10 @@ public function __construct( private readonly PromoCodeRepositoryInterface $promoCodeRepository, private readonly ProductRepositoryInterface $productRepository, private readonly EventStatisticRepositoryInterface $eventStatisticsRepository, - private readonly EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository, - private readonly DatabaseManager $databaseManager, + private readonly EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository, + private readonly EventOccurrenceStatisticRepositoryInterface $eventOccurrenceStatisticRepository, + private readonly EventOccurrenceDailyStatisticRepositoryInterface $eventOccurrenceDailyStatisticRepository, + private readonly DatabaseManager $databaseManager, private readonly OrderRepositoryInterface $orderRepository, private readonly LoggerInterface $logger, private readonly Retrier $retrier, @@ -52,6 +57,8 @@ public function incrementForOrder(OrderDomainObject $order): void $this->databaseManager->transaction(function () use ($order): void { $this->incrementAggregateStatistics($order); $this->incrementDailyStatistics($order); + $this->incrementOccurrenceStatistics($order); + $this->incrementOccurrenceDailyStatistics($order); $this->incrementPromoCodeUsage($order); $this->incrementProductStatistics($order); }); @@ -241,6 +248,158 @@ private function incrementDailyStatistics(OrderDomainObject $order): void ); } + /** + * Increment occurrence statistics, grouped by occurrence_id from order items + * + * @throws EventStatisticsVersionMismatchException + */ + private function incrementOccurrenceStatistics(OrderDomainObject $order): void + { + $itemsByOccurrence = []; + foreach ($order->getOrderItems() as $orderItem) { + $occId = $orderItem->getEventOccurrenceId(); + if ($occId === null) { + continue; + } + $itemsByOccurrence[$occId][] = $orderItem; + } + + foreach ($itemsByOccurrence as $occurrenceId => $items) { + $productsSold = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getQuantity(), $items)); + $attendeesRegistered = array_sum(array_map( + fn(OrderItemDomainObject $i) => $i->getProductType() === ProductType::TICKET->name ? $i->getQuantity() : 0, + $items, + )); + $totalGross = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalGross(), $items)); + $totalBeforeAdditions = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalBeforeAdditions(), $items)); + $totalTax = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalTax() ?? 0, $items)); + $totalFee = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalServiceFee() ?? 0, $items)); + + $existing = $this->eventOccurrenceStatisticRepository->findFirstWhere([ + 'event_id' => $order->getEventId(), + 'event_occurrence_id' => $occurrenceId, + ]); + + if ($existing === null) { + $this->eventOccurrenceStatisticRepository->create([ + 'event_id' => $order->getEventId(), + 'event_occurrence_id' => $occurrenceId, + 'products_sold' => $productsSold, + 'attendees_registered' => $attendeesRegistered, + 'sales_total_gross' => $totalGross, + 'sales_total_before_additions' => $totalBeforeAdditions, + 'total_tax' => $totalTax, + 'total_fee' => $totalFee, + 'orders_created' => 1, + 'orders_cancelled' => 0, + ]); + continue; + } + + $updates = [ + 'products_sold' => $existing->getProductsSold() + $productsSold, + 'attendees_registered' => $existing->getAttendeesRegistered() + $attendeesRegistered, + 'sales_total_gross' => $existing->getSalesTotalGross() + $totalGross, + 'sales_total_before_additions' => $existing->getSalesTotalBeforeAdditions() + $totalBeforeAdditions, + 'total_tax' => $existing->getTotalTax() + $totalTax, + 'total_fee' => $existing->getTotalFee() + $totalFee, + 'orders_created' => $existing->getOrdersCreated() + 1, + 'version' => $existing->getVersion() + 1, + ]; + + $updated = $this->eventOccurrenceStatisticRepository->updateWhere( + attributes: $updates, + where: [ + 'event_occurrence_id' => $occurrenceId, + 'version' => $existing->getVersion(), + ] + ); + + if ($updated === 0) { + throw new EventStatisticsVersionMismatchException( + 'Occurrence statistics version mismatch for occurrence ' . $occurrenceId + ); + } + } + } + + /** + * @throws EventStatisticsVersionMismatchException + */ + private function incrementOccurrenceDailyStatistics(OrderDomainObject $order): void + { + $orderDate = (new Carbon($order->getCreatedAt()))->format('Y-m-d'); + + $itemsByOccurrence = []; + foreach ($order->getOrderItems() as $orderItem) { + $occId = $orderItem->getEventOccurrenceId(); + if ($occId === null) { + continue; + } + $itemsByOccurrence[$occId][] = $orderItem; + } + + foreach ($itemsByOccurrence as $occurrenceId => $items) { + $productsSold = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getQuantity(), $items)); + $attendeesRegistered = array_sum(array_map( + fn(OrderItemDomainObject $i) => $i->getProductType() === ProductType::TICKET->name ? $i->getQuantity() : 0, + $items, + )); + $totalGross = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalGross(), $items)); + $totalBeforeAdditions = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalBeforeAdditions(), $items)); + $totalTax = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalTax() ?? 0, $items)); + $totalFee = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalServiceFee() ?? 0, $items)); + + $existing = $this->eventOccurrenceDailyStatisticRepository->findFirstWhere([ + 'event_occurrence_id' => $occurrenceId, + 'date' => $orderDate, + ]); + + if ($existing === null) { + $this->eventOccurrenceDailyStatisticRepository->create([ + 'event_id' => $order->getEventId(), + 'event_occurrence_id' => $occurrenceId, + 'date' => $orderDate, + 'products_sold' => $productsSold, + 'attendees_registered' => $attendeesRegistered, + 'sales_total_gross' => $totalGross, + 'sales_total_before_additions' => $totalBeforeAdditions, + 'total_tax' => $totalTax, + 'total_fee' => $totalFee, + 'orders_created' => 1, + 'orders_cancelled' => 0, + ]); + continue; + } + + $updates = [ + 'products_sold' => $existing->getProductsSold() + $productsSold, + 'attendees_registered' => $existing->getAttendeesRegistered() + $attendeesRegistered, + 'sales_total_gross' => $existing->getSalesTotalGross() + $totalGross, + 'sales_total_before_additions' => $existing->getSalesTotalBeforeAdditions() + $totalBeforeAdditions, + 'total_tax' => $existing->getTotalTax() + $totalTax, + 'total_fee' => $existing->getTotalFee() + $totalFee, + 'orders_created' => $existing->getOrdersCreated() + 1, + 'version' => $existing->getVersion() + 1, + ]; + + $updated = $this->eventOccurrenceDailyStatisticRepository->updateWhere( + attributes: $updates, + where: [ + 'event_occurrence_id' => $occurrenceId, + 'date' => $orderDate, + 'version' => $existing->getVersion(), + ] + ); + + if ($updated === 0) { + throw new EventStatisticsVersionMismatchException( + 'Occurrence daily statistics version mismatch for occurrence ' . $occurrenceId + ); + } + } + } + /** * Increment promo code usage counts */ diff --git a/backend/app/Services/Domain/EventStatistics/EventStatisticsReactivationService.php b/backend/app/Services/Domain/EventStatistics/EventStatisticsReactivationService.php index 61428ec45b..8e55c7dab5 100644 --- a/backend/app/Services/Domain/EventStatistics/EventStatisticsReactivationService.php +++ b/backend/app/Services/Domain/EventStatistics/EventStatisticsReactivationService.php @@ -6,6 +6,8 @@ use HiEvents\Exceptions\EventStatisticsVersionMismatchException; use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceDailyStatisticRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface; use HiEvents\Services\Infrastructure\Utlitiy\Retry\Retrier; use Illuminate\Database\DatabaseManager; @@ -18,8 +20,10 @@ class EventStatisticsReactivationService { public function __construct( private readonly EventStatisticRepositoryInterface $eventStatisticsRepository, - private readonly EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository, - private readonly LoggerInterface $logger, + private readonly EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository, + private readonly EventOccurrenceStatisticRepositoryInterface $eventOccurrenceStatisticRepository, + private readonly EventOccurrenceDailyStatisticRepositoryInterface $eventOccurrenceDailyStatisticRepository, + private readonly LoggerInterface $logger, private readonly DatabaseManager $databaseManager, private readonly Retrier $retrier, ) @@ -30,13 +34,17 @@ public function __construct( * @throws EventStatisticsVersionMismatchException * @throws Throwable */ - public function incrementForReactivatedAttendee(int $eventId, string $orderDate, int $attendeeCount = 1): void + public function incrementForReactivatedAttendee(int $eventId, string $orderDate, int $attendeeCount = 1, ?int $occurrenceId = null): void { $this->retrier->retry( - callableAction: function () use ($eventId, $orderDate, $attendeeCount): void { - $this->databaseManager->transaction(function () use ($eventId, $orderDate, $attendeeCount): void { + callableAction: function () use ($eventId, $orderDate, $attendeeCount, $occurrenceId): void { + $this->databaseManager->transaction(function () use ($eventId, $orderDate, $attendeeCount, $occurrenceId): void { $this->incrementAggregateAttendeeStatistics($eventId, $attendeeCount); $this->incrementDailyAttendeeStatistics($eventId, $orderDate, $attendeeCount); + if ($occurrenceId !== null) { + $this->incrementOccurrenceAttendeeStatistics($occurrenceId, $attendeeCount); + $this->incrementOccurrenceDailyAttendeeStatistics($occurrenceId, $orderDate, $attendeeCount); + } }); }, onFailure: function (int $attempt, Throwable $e) use ($eventId, $orderDate, $attendeeCount): void { @@ -153,4 +161,74 @@ private function incrementDailyAttendeeStatistics(int $eventId, string $orderDat ] ); } + + /** + * @throws EventStatisticsVersionMismatchException + */ + private function incrementOccurrenceAttendeeStatistics(int $occurrenceId, int $attendeeCount): void + { + $existing = $this->eventOccurrenceStatisticRepository->findFirstWhere([ + 'event_occurrence_id' => $occurrenceId, + ]); + + if (!$existing) { + return; + } + + $updates = [ + 'attendees_registered' => $existing->getAttendeesRegistered() + $attendeeCount, + 'version' => $existing->getVersion() + 1, + ]; + + $updated = $this->eventOccurrenceStatisticRepository->updateWhere( + attributes: $updates, + where: [ + 'event_occurrence_id' => $occurrenceId, + 'version' => $existing->getVersion(), + ] + ); + + if ($updated === 0) { + throw new EventStatisticsVersionMismatchException( + 'Occurrence statistics version mismatch for occurrence ' . $occurrenceId + ); + } + } + + /** + * @throws EventStatisticsVersionMismatchException + */ + private function incrementOccurrenceDailyAttendeeStatistics(int $occurrenceId, string $orderDate, int $attendeeCount): void + { + $formattedDate = (new Carbon($orderDate))->format('Y-m-d'); + + $existing = $this->eventOccurrenceDailyStatisticRepository->findFirstWhere([ + 'event_occurrence_id' => $occurrenceId, + 'date' => $formattedDate, + ]); + + if (!$existing) { + return; + } + + $updates = [ + 'attendees_registered' => $existing->getAttendeesRegistered() + $attendeeCount, + 'version' => $existing->getVersion() + 1, + ]; + + $updated = $this->eventOccurrenceDailyStatisticRepository->updateWhere( + attributes: $updates, + where: [ + 'event_occurrence_id' => $occurrenceId, + 'date' => $formattedDate, + 'version' => $existing->getVersion(), + ] + ); + + if ($updated === 0) { + throw new EventStatisticsVersionMismatchException( + 'Occurrence daily statistics version mismatch for occurrence ' . $occurrenceId + ); + } + } } diff --git a/backend/app/Services/Domain/EventStatistics/EventStatisticsRefundService.php b/backend/app/Services/Domain/EventStatistics/EventStatisticsRefundService.php index 75dbfa6a7e..7e7397b154 100644 --- a/backend/app/Services/Domain/EventStatistics/EventStatisticsRefundService.php +++ b/backend/app/Services/Domain/EventStatistics/EventStatisticsRefundService.php @@ -5,19 +5,27 @@ namespace HiEvents\Services\Domain\EventStatistics; use HiEvents\DomainObjects\OrderDomainObject; +use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceDailyStatisticRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface; +use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Values\MoneyValue; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\DB; use Psr\Log\LoggerInterface; use Symfony\Component\Routing\Exception\ResourceNotFoundException; class EventStatisticsRefundService { public function __construct( - private readonly EventStatisticRepositoryInterface $eventStatisticsRepository, - private readonly EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository, - private readonly LoggerInterface $logger, + private readonly EventStatisticRepositoryInterface $eventStatisticsRepository, + private readonly EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository, + private readonly EventOccurrenceStatisticRepositoryInterface $eventOccurrenceStatisticRepository, + private readonly EventOccurrenceDailyStatisticRepositoryInterface $eventOccurrenceDailyStatisticRepository, + private readonly OrderRepositoryInterface $orderRepository, + private readonly LoggerInterface $logger, ) { } @@ -29,6 +37,29 @@ public function updateForRefund(OrderDomainObject $order, MoneyValue $refundAmou { $this->updateAggregateStatisticsForRefund($order, $refundAmount); $this->updateDailyStatisticsForRefund($order, $refundAmount); + + // Occurrence stats need order items eager-loaded; the aggregate / daily paths + // do not. Load + group once and pass the result down so the per-occurrence and + // per-occurrence-per-day updates do not each repeat the SELECT and the in-memory + // grouping. Skips the occurrence pass entirely for non-recurring orders. + $orderWithItems = $this->orderRepository + ->loadRelation(OrderItemDomainObject::class) + ->findById($order->getId()); + + if ($orderWithItems->getTotalGross() <= 0) { + return; + } + + $itemsByOccurrence = $this->groupItemsByOccurrence($orderWithItems); + if (empty($itemsByOccurrence)) { + return; + } + + $refundProportion = $refundAmount->toFloat() / $orderWithItems->getTotalGross(); + $orderDate = (new Carbon($orderWithItems->getCreatedAt()))->format('Y-m-d'); + + $this->updateOccurrenceStatisticsForRefund($itemsByOccurrence, $refundProportion); + $this->updateOccurrenceDailyStatisticsForRefund($itemsByOccurrence, $refundProportion, $orderDate); } /** @@ -141,4 +172,95 @@ private function updateDailyStatisticsForRefund(OrderDomainObject $order, MoneyV ] ); } + + /** + * Atomically applies the refund delta to per-occurrence stats. Uses raw SQL increments + * (rather than read-modify-write) so concurrent refunds on the same occurrence cannot + * lose updates. Version is bumped so any concurrent reader using optimistic locking + * (e.g. EventStatisticsIncrementService) detects the change. + * + * @param array $itemsByOccurrence + */ + private function updateOccurrenceStatisticsForRefund(array $itemsByOccurrence, float $refundProportion): void + { + foreach ($itemsByOccurrence as $occurrenceId => $items) { + $occurrenceGross = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalGross() ?? 0, $items)); + $occurrenceTax = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalTax() ?? 0, $items)); + $occurrenceFee = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalServiceFee() ?? 0, $items)); + + $grossDelta = $this->formatDelta($occurrenceGross * $refundProportion); + $taxDelta = $this->formatDelta($occurrenceTax * $refundProportion); + $feeDelta = $this->formatDelta($occurrenceFee * $refundProportion); + + $this->eventOccurrenceStatisticRepository->updateWhere( + attributes: [ + 'sales_total_gross' => DB::raw("GREATEST(0, sales_total_gross - {$grossDelta})"), + 'total_refunded' => DB::raw("total_refunded + {$grossDelta}"), + 'total_tax' => DB::raw("GREATEST(0, total_tax - {$taxDelta})"), + 'total_fee' => DB::raw("GREATEST(0, total_fee - {$feeDelta})"), + 'version' => DB::raw('version + 1'), + ], + where: [ + 'event_occurrence_id' => $occurrenceId, + ] + ); + } + } + + /** + * Atomic per-occurrence-per-day refund stats update. See updateOccurrenceStatisticsForRefund + * for the rationale behind raw SQL increments. + * + * @param array $itemsByOccurrence + */ + private function updateOccurrenceDailyStatisticsForRefund(array $itemsByOccurrence, float $refundProportion, string $orderDate): void + { + foreach ($itemsByOccurrence as $occurrenceId => $items) { + $occurrenceGross = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalGross() ?? 0, $items)); + $occurrenceTax = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalTax() ?? 0, $items)); + $occurrenceFee = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalServiceFee() ?? 0, $items)); + + $grossDelta = $this->formatDelta($occurrenceGross * $refundProportion); + $taxDelta = $this->formatDelta($occurrenceTax * $refundProportion); + $feeDelta = $this->formatDelta($occurrenceFee * $refundProportion); + + $this->eventOccurrenceDailyStatisticRepository->updateWhere( + attributes: [ + 'sales_total_gross' => DB::raw("GREATEST(0, sales_total_gross - {$grossDelta})"), + 'total_refunded' => DB::raw("total_refunded + {$grossDelta}"), + 'total_tax' => DB::raw("GREATEST(0, total_tax - {$taxDelta})"), + 'total_fee' => DB::raw("GREATEST(0, total_fee - {$feeDelta})"), + 'version' => DB::raw('version + 1'), + ], + where: [ + 'event_occurrence_id' => $occurrenceId, + 'date' => $orderDate, + ] + ); + } + } + + /** + * Locale-safe float-to-SQL formatter for inline numeric literals. + */ + private function formatDelta(float $value): string + { + return number_format($value, 4, '.', ''); + } + + /** + * @return array + */ + private function groupItemsByOccurrence(OrderDomainObject $order): array + { + $itemsByOccurrence = []; + foreach ($order->getOrderItems() as $orderItem) { + $occId = $orderItem->getEventOccurrenceId(); + if ($occId === null) { + continue; + } + $itemsByOccurrence[$occId][] = $orderItem; + } + return $itemsByOccurrence; + } } diff --git a/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php b/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php index 833ac9b164..aba0eecc05 100644 --- a/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php +++ b/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php @@ -29,16 +29,14 @@ class SendEventEmailMessagesService private array $sentEmails = []; public function __construct( - private readonly OrderRepositoryInterface $orderRepository, + private readonly OrderRepositoryInterface $orderRepository, private readonly AttendeeRepositoryInterface $attendeeRepository, - private readonly EventRepositoryInterface $eventRepository, - private readonly MessageRepositoryInterface $messageRepository, - private readonly UserRepositoryInterface $userRepository, - private readonly Logger $logger, - private readonly Dispatcher $dispatcher, - ) - { - } + private readonly EventRepositoryInterface $eventRepository, + private readonly MessageRepositoryInterface $messageRepository, + private readonly UserRepositoryInterface $userRepository, + private readonly Logger $logger, + private readonly Dispatcher $dispatcher, + ) {} /** * @throws UnableToSendMessageException @@ -58,7 +56,7 @@ public function send(SendMessageDTO $messageData): void 'event_id' => $messageData->event_id, ]); - if ((!$order && $messageData->type === MessageTypeEnum::ORDER_OWNER) || !$messageData->id) { + if ((! $order && $messageData->type === MessageTypeEnum::ORDER_OWNER) || ! $messageData->id) { $message = 'Unable to send message. Order or message ID not present.'; $this->logger->error($message, $messageData->toArray()); $this->updateMessageStatus($messageData, MessageStatus::FAILED); @@ -103,13 +101,15 @@ private function sendAttendeeMessages(SendMessageDTO $messageData, EventDomainOb private function sendTicketHolderMessages(SendMessageDTO $messageData, EventDomainObject $event): void { + $additionalWhere = array_merge([ + 'event_id' => $messageData->event_id, + 'status' => AttendeeStatus::ACTIVE->name, + ], $this->occurrenceWhere($messageData)); + $attendees = $this->attendeeRepository->findWhereIn( field: 'product_id', values: $messageData->product_ids, - additionalWhere: [ - 'event_id' => $messageData->event_id, - 'status' => AttendeeStatus::ACTIVE->name, - ], + additionalWhere: $additionalWhere, columns: ['first_name', 'last_name', 'email'] ); @@ -117,11 +117,10 @@ private function sendTicketHolderMessages(SendMessageDTO $messageData, EventDoma } private function sendOrderMessages( - SendMessageDTO $messageData, + SendMessageDTO $messageData, EventDomainObject $event, OrderDomainObject $order, - ): void - { + ): void { $this->sendEmailToMessageSender($messageData, $event); $this->sendMessage( @@ -133,11 +132,10 @@ private function sendOrderMessages( } private function emailAttendees( - Collection $attendees, - SendMessageDTO $messageData, + Collection $attendees, + SendMessageDTO $messageData, EventDomainObject $event, - ): void - { + ): void { $this->sendEmailToMessageSender($messageData, $event); if ($messageData->is_test) { @@ -184,20 +182,39 @@ private function updateMessageStatus(SendMessageDTO $messageData, MessageStatus */ private function sendEventMessages(SendMessageDTO $messageData, EventDomainObject $event): void { + $where = array_merge([ + 'event_id' => $messageData->event_id, + 'status' => AttendeeStatus::ACTIVE->name, + ], $this->occurrenceWhere($messageData)); + $attendees = $this->attendeeRepository->findWhere( - where: [ - 'event_id' => $messageData->event_id, - 'status' => AttendeeStatus::ACTIVE->name, - ], + where: $where, columns: ['first_name', 'last_name', 'email'] ); $this->emailAttendees($attendees, $messageData, $event); } + /** + * Returns the `where` fragment scoping to the target occurrences. + * event_occurrence_ids wins over event_occurrence_id when both are set + * (the validator forbids that combo anyway). + */ + private function occurrenceWhere(SendMessageDTO $messageData): array + { + if (! empty($messageData->event_occurrence_ids)) { + return [['event_occurrence_id', 'in', $messageData->event_occurrence_ids]]; + } + if ($messageData->event_occurrence_id) { + return ['event_occurrence_id' => $messageData->event_occurrence_id]; + } + + return []; + } + private function sendEmailToMessageSender(SendMessageDTO $messageData, EventDomainObject $event): void { - if (!$messageData->send_copy_to_current_user && !$messageData->is_test) { + if (! $messageData->send_copy_to_current_user && ! $messageData->is_test) { return; } @@ -216,7 +233,9 @@ private function sendProductMessages(SendMessageDTO $messageData, EventDomainObj $orders = $this->orderRepository->findOrdersAssociatedWithProducts( eventId: $messageData->event_id, productIds: $messageData->product_ids, - orderStatuses: $messageData->order_statuses + orderStatuses: $messageData->order_statuses, + eventOccurrenceId: $messageData->event_occurrence_id, + eventOccurrenceIds: $messageData->event_occurrence_ids, ); if ($orders->isEmpty()) { @@ -236,12 +255,11 @@ private function sendProductMessages(SendMessageDTO $messageData, EventDomainObj } private function sendMessage( - string $emailAddress, - string $fullName, - SendMessageDTO $messageData, + string $emailAddress, + string $fullName, + SendMessageDTO $messageData, EventDomainObject $event, - ): void - { + ): void { if (in_array($emailAddress, $this->sentEmails, true)) { return; } diff --git a/backend/app/Services/Domain/Mail/SendOrderDetailsService.php b/backend/app/Services/Domain/Mail/SendOrderDetailsService.php index a1b139d650..6858b6ed45 100644 --- a/backend/app/Services/Domain/Mail/SendOrderDetailsService.php +++ b/backend/app/Services/Domain/Mail/SendOrderDetailsService.php @@ -4,13 +4,14 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\InvoiceDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; +use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\Mail\Order\OrderFailed; -use HiEvents\Mail\Order\OrderSummary; use HiEvents\Mail\Organizer\OrderSummaryForOrganizer; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\EventRepositoryInterface; @@ -34,14 +35,35 @@ public function __construct( public function sendOrderSummaryAndTicketEmails(OrderDomainObject $order): void { $order = $this->orderRepository - ->loadRelation(OrderItemDomainObject::class) - ->loadRelation(AttendeeDomainObject::class) + ->loadRelation(new Relationship( + domainObject: OrderItemDomainObject::class, + nested: [ + new Relationship( + domainObject: EventOccurrenceDomainObject::class, + name: 'event_occurrence', + ), + ], + )) + ->loadRelation(new Relationship( + domainObject: AttendeeDomainObject::class, + nested: [ + new Relationship( + domainObject: EventOccurrenceDomainObject::class, + name: 'event_occurrence', + ), + new Relationship( + domainObject: ProductDomainObject::class, + name: 'product', + ), + ], + )) ->loadRelation(InvoiceDomainObject::class) ->findById($order->getId()); $event = $this->eventRepository ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) ->loadRelation(new Relationship(EventSettingDomainObject::class)) + ->loadRelation(new Relationship(EventOccurrenceDomainObject::class)) ->findById($order->getEventId()); if ($order->isOrderCompleted() || $order->isOrderAwaitingOfflinePayment()) { @@ -63,11 +85,12 @@ public function sendOrderSummaryAndTicketEmails(OrderDomainObject $order): void } public function sendCustomerOrderSummary( - OrderDomainObject $order, - EventDomainObject $event, - OrganizerDomainObject $organizer, - EventSettingDomainObject $eventSettings, - ?InvoiceDomainObject $invoice = null + OrderDomainObject $order, + EventDomainObject $event, + OrganizerDomainObject $organizer, + EventSettingDomainObject $eventSettings, + ?InvoiceDomainObject $invoice = null, + ?EventOccurrenceDomainObject $occurrence = null, ): void { $mail = $this->mailBuilderService->buildOrderSummaryMail( @@ -75,7 +98,8 @@ public function sendCustomerOrderSummary( $event, $eventSettings, $organizer, - $invoice + $invoice, + $occurrence ?? $this->resolvePrimaryOccurrence($order), ); $this->mailer @@ -84,6 +108,26 @@ public function sendCustomerOrderSummary( ->send($mail); } + /** + * Single-occurrence orders return that occurrence so the email can show its + * date. Multi-occurrence series-pass orders return null (email falls back + * to the event-level range). + */ + private function resolvePrimaryOccurrence(OrderDomainObject $order): ?EventOccurrenceDomainObject + { + $items = $order->getOrderItems(); + if ($items === null || $items->isEmpty()) { + return null; + } + + $distinct = $items + ->map(fn(OrderItemDomainObject $item) => $item->getEventOccurrence()) + ->filter() + ->unique(fn(EventOccurrenceDomainObject $occ) => $occ->getId()); + + return $distinct->count() === 1 ? $distinct->first() : null; + } + private function sendAttendeeTicketEmails(OrderDomainObject $order, EventDomainObject $event): void { $sentEmails = []; diff --git a/backend/app/Services/Domain/Message/MessageDispatchService.php b/backend/app/Services/Domain/Message/MessageDispatchService.php index 006e666efa..3cb73f54b9 100644 --- a/backend/app/Services/Domain/Message/MessageDispatchService.php +++ b/backend/app/Services/Domain/Message/MessageDispatchService.php @@ -17,22 +17,21 @@ class MessageDispatchService { public function __construct( private readonly MessageRepositoryInterface $messageRepository, - ) - { - } + ) {} public function dispatchMessage(MessageDomainObject $message, MessageStatus $expectedStatus = MessageStatus::SCHEDULED): void { $sendData = $message->getSendData(); $sendDataArray = is_string($sendData) ? json_decode($sendData, true) : $sendData; - if (!is_array($sendDataArray) || !isset($sendDataArray['account_id'])) { + if (! is_array($sendDataArray) || ! isset($sendDataArray['account_id'])) { Log::error('Message has invalid send_data, marking as FAILED', [ 'message_id' => $message->getId(), ]); $this->messageRepository->updateFromArray($message->getId(), [ 'status' => MessageStatus::FAILED->name, ]); + return; } @@ -45,6 +44,7 @@ public function dispatchMessage(MessageDomainObject $message, MessageStatus $exp Log::info('Message status changed before dispatch, skipping', [ 'message_id' => $message->getId(), ]); + return; } @@ -63,6 +63,8 @@ public function dispatchMessage(MessageDomainObject $message, MessageStatus $exp id: $message->getId(), attendee_ids: $message->getAttendeeIds() ?? [], product_ids: $message->getProductIds() ?? [], + event_occurrence_id: $message->getEventOccurrenceId(), + event_occurrence_ids: $sendDataArray['event_occurrence_ids'] ?? null, )); } catch (Throwable $e) { Log::error('Failed to dispatch SendMessagesJob, reverting status', [ diff --git a/backend/app/Services/Domain/Message/MessagingEligibilityService.php b/backend/app/Services/Domain/Message/MessagingEligibilityService.php index 5a5b1df3bf..f18b9a6d9f 100644 --- a/backend/app/Services/Domain/Message/MessagingEligibilityService.php +++ b/backend/app/Services/Domain/Message/MessagingEligibilityService.php @@ -4,14 +4,15 @@ use Carbon\Carbon; use HiEvents\DomainObjects\AccountMessagingTierDomainObject; -use HiEvents\DomainObjects\AccountStripePlatformDomainObject; use HiEvents\DomainObjects\Enums\MessagingEligibilityFailureEnum; use HiEvents\DomainObjects\Enums\MessagingTierViolationEnum; +use HiEvents\DomainObjects\OrganizerStripePlatformDomainObject; use HiEvents\Repository\Interfaces\AccountMessagingTierRepositoryInterface; use HiEvents\Repository\Interfaces\AccountRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\MessageRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; +use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; use HiEvents\Services\Domain\Message\DTO\MessagingEligibilityFailureDTO; use HiEvents\Services\Domain\Message\DTO\MessagingTierViolationDTO; @@ -25,14 +26,13 @@ public function __construct( private readonly MessageRepositoryInterface $messageRepository, private readonly AccountMessagingTierRepositoryInterface $accountMessagingTierRepository, private readonly OrderRepositoryInterface $orderRepository, + private readonly OrganizerRepositoryInterface $organizerRepository, ) { } public function checkEligibility(int $accountId, int $eventId): ?MessagingEligibilityFailureDTO { - $account = $this->accountRepository - ->loadRelation(AccountStripePlatformDomainObject::class) - ->findById($accountId); + $account = $this->accountRepository->findById($accountId); $tier = $this->getAccountMessagingTier($account->getAccountMessagingTierId()); @@ -41,9 +41,15 @@ public function checkEligibility(int $accountId, int $eventId): ?MessagingEligib return null; } + $event = $this->eventRepository->findById($eventId); + + $organizer = $this->organizerRepository + ->loadRelation(OrganizerStripePlatformDomainObject::class) + ->findById($event->getOrganizerId()); + $failures = []; - if (!$account->isStripeSetupComplete()) { + if (!$organizer || !$organizer->isStripeSetupComplete()) { $failures[] = MessagingEligibilityFailureEnum::STRIPE_NOT_CONNECTED; } @@ -51,7 +57,6 @@ public function checkEligibility(int $accountId, int $eventId): ?MessagingEligib $failures[] = MessagingEligibilityFailureEnum::NO_PAID_ORDERS; } - $event = $this->eventRepository->findById($eventId); if ($this->isEventTooNew($event->getCreatedAt())) { $failures[] = MessagingEligibilityFailureEnum::EVENT_TOO_NEW; } diff --git a/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php b/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php index e04a5f9187..5878bbd498 100644 --- a/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php +++ b/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php @@ -3,16 +3,16 @@ namespace HiEvents\Services\Domain\Order; use Brick\Math\Exception\MathException; -use HiEvents\DomainObjects\AccountConfigurationDomainObject; -use HiEvents\DomainObjects\AccountDomainObject; use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\Enums\PaymentProviders; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract; use HiEvents\DomainObjects\InvoiceDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; +use HiEvents\DomainObjects\OrganizerConfigurationDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\Status\AttendeeStatus; use HiEvents\DomainObjects\Status\InvoiceStatus; @@ -24,6 +24,7 @@ use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\AffiliateRepositoryInterface; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\InvoiceRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; @@ -37,19 +38,18 @@ class MarkOrderAsPaidService { public function __construct( - private readonly OrderRepositoryInterface $orderRepository, - private readonly DatabaseManager $databaseManager, - private readonly AffiliateRepositoryInterface $affiliateRepository, - private readonly InvoiceRepositoryInterface $invoiceRepository, - private readonly AttendeeRepositoryInterface $attendeeRepository, - private readonly DomainEventDispatcherService $domainEventDispatcherService, + private readonly OrderRepositoryInterface $orderRepository, + private readonly DatabaseManager $databaseManager, + private readonly AffiliateRepositoryInterface $affiliateRepository, + private readonly InvoiceRepositoryInterface $invoiceRepository, + private readonly AttendeeRepositoryInterface $attendeeRepository, + private readonly DomainEventDispatcherService $domainEventDispatcherService, private readonly OrderApplicationFeeCalculationService $orderApplicationFeeCalculationService, - private readonly EventRepositoryInterface $eventRepository, - private readonly OrderApplicationFeeService $orderApplicationFeeService, - private readonly SendOrderDetailsService $sendOrderDetailsService, - ) - { - } + private readonly EventRepositoryInterface $eventRepository, + private readonly OrderApplicationFeeService $orderApplicationFeeService, + private readonly SendOrderDetailsService $sendOrderDetailsService, + private readonly EventOccurrenceRepositoryInterface $occurrenceRepository, + ) {} /** * @throws ResourceConflictException|Throwable @@ -57,8 +57,7 @@ public function __construct( public function markOrderAsPaid( int $orderId, int $eventId, - ): OrderDomainObject - { + ): OrderDomainObject { return $this->databaseManager->transaction(function () use ($orderId, $eventId) { /** @var OrderDomainObject $order */ $order = $this->orderRepository @@ -73,18 +72,33 @@ public function markOrderAsPaid( $event = $this->eventRepository ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) ->loadRelation(new Relationship(EventSettingDomainObject::class)) + ->loadRelation(new Relationship(EventOccurrenceDomainObject::class)) ->findById($order->getEventId()); if ($order->getStatus() !== OrderStatus::AWAITING_OFFLINE_PAYMENT->name) { throw new ResourceConflictException(__('Order is not awaiting offline payment')); } + // Mirror CompleteOrderHandler::validateOccurrenceStatus — an offline + // order can sit AWAITING_OFFLINE_PAYMENT for days and have its + // sessions cancelled or pass into the past in the meantime. Marking + // it paid would otherwise issue ACTIVE attendees for a dead date. + $this->validateOccurrenceStatus($order); + $this->updateOrderStatus($orderId); $this->updateOrderInvoice($orderId); $updatedOrder = $this->orderRepository - ->loadRelation(OrderItemDomainObject::class) + ->loadRelation(new Relationship( + domainObject: OrderItemDomainObject::class, + nested: [ + new Relationship( + domainObject: EventOccurrenceDomainObject::class, + name: 'event_occurrence', + ), + ], + )) ->findById($orderId); // Update affiliate sales if this order has an affiliate @@ -123,6 +137,34 @@ public function markOrderAsPaid( }); } + /** + * @throws ResourceConflictException + */ + private function validateOccurrenceStatus(OrderDomainObject $order): void + { + $occurrenceIds = $order->getOrderItems() + ?->map(fn (OrderItemDomainObject $item) => $item->getEventOccurrenceId()) + ->filter() + ->unique() + ->values(); + + if ($occurrenceIds === null || $occurrenceIds->isEmpty()) { + return; + } + + $occurrences = $this->occurrenceRepository->findWhereIn('id', $occurrenceIds->toArray()); + + foreach ($occurrences as $occurrence) { + if ($occurrence->isCancelled()) { + throw new ResourceConflictException(__('This event date has been cancelled')); + } + + if ($occurrence->isPast()) { + throw new ResourceConflictException(__('This event date has already ended')); + } + } + } + private function updateOrderInvoice(int $orderId): void { $invoice = $this->invoiceRepository->findLatestInvoiceForOrder($orderId); @@ -163,24 +205,26 @@ private function storeApplicationFeePayment(OrderDomainObject $updatedOrder): vo /** @var EventDomainObject $event */ $event = $this->eventRepository ->loadRelation(new Relationship( - domainObject: AccountDomainObject::class, + domainObject: OrganizerDomainObject::class, nested: [ new Relationship( - domainObject: AccountConfigurationDomainObject::class, - name: 'configuration', + domainObject: OrganizerConfigurationDomainObject::class, + name: 'organizer_configuration', ), ], - name: 'account' + name: 'organizer' )) ->findById($updatedOrder->getEventId()); - /** @var AccountConfigurationDomainObject $config */ - $config = $event->getAccount()->getConfiguration(); + $config = $event->getOrganizer()?->getOrganizerConfiguration(); + if (!$config) { + return; + } $this->orderApplicationFeeService->createOrderApplicationFee( orderId: $updatedOrder->getId(), applicationFeeAmountMinorUnit: $this->orderApplicationFeeCalculationService->calculateApplicationFee( - accountConfiguration: $config, + configuration: $config, order: $updatedOrder, )?->netApplicationFee?->toMinorUnit() ?? 0, orderApplicationFeeStatus: OrderApplicationFeeStatus::AWAITING_PAYMENT, diff --git a/backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php b/backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php index 69c81ff8b7..c518633626 100644 --- a/backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php +++ b/backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php @@ -3,8 +3,8 @@ namespace HiEvents\Services\Domain\Order; use Brick\Money\Currency; -use HiEvents\DomainObjects\AccountConfigurationDomainObject; -use HiEvents\DomainObjects\AccountVatSettingDomainObject; +use HiEvents\DomainObjects\OrganizerConfigurationDomainObject; +use HiEvents\DomainObjects\OrganizerVatSettingDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\Services\Domain\Order\DTO\ApplicationFeeValuesDTO; use HiEvents\Services\Domain\Order\Vat\VatRateDeterminationService; @@ -23,9 +23,9 @@ public function __construct( } public function calculateApplicationFee( - AccountConfigurationDomainObject $accountConfiguration, + OrganizerConfigurationDomainObject $configuration, OrderDomainObject $order, - ?AccountVatSettingDomainObject $vatSettings = null + ?OrganizerVatSettingDomainObject $vatSettings = null ): ?ApplicationFeeValuesDTO { $currency = $order->getCurrency(); @@ -35,8 +35,8 @@ public function calculateApplicationFee( return null; } - $fixedFee = $this->getConvertedFixedFee($accountConfiguration, $currency); - $percentageFee = $accountConfiguration->getPercentageApplicationFee(); + $fixedFee = $this->getConvertedFixedFee($configuration, $currency); + $percentageFee = $configuration->getPercentageApplicationFee(); $netApplicationFee = MoneyValue::fromFloat( amount: ($fixedFee->toFloat() * $quantityPurchased) + ($order->getTotalGross() * $percentageFee / 100), @@ -58,20 +58,20 @@ public function calculateApplicationFee( } private function getConvertedFixedFee( - AccountConfigurationDomainObject $accountConfiguration, + OrganizerConfigurationDomainObject $configuration, string $currency ): MoneyValue { - $baseCurrency = $accountConfiguration->getApplicationFeeCurrency(); + $baseCurrency = $configuration->getApplicationFeeCurrency(); if ($currency === $baseCurrency) { - return MoneyValue::fromFloat($accountConfiguration->getFixedApplicationFee(), $currency); + return MoneyValue::fromFloat($configuration->getFixedApplicationFee(), $currency); } return $this->currencyConversionClient->convert( fromCurrency: Currency::of($baseCurrency), toCurrency: Currency::of($currency), - amount: $accountConfiguration->getFixedApplicationFee() + amount: $configuration->getFixedApplicationFee() ); } @@ -98,7 +98,7 @@ private function getChargeableQuantityPurchased(OrderDomainObject $order): int * - Gross charged: £0.72 (£0.60 + £0.12) */ private function calculateFeeWithVat( - AccountVatSettingDomainObject $vatSettings, + OrganizerVatSettingDomainObject $vatSettings, MoneyValue $netApplicationFee, string $currency, ): ApplicationFeeValuesDTO diff --git a/backend/app/Services/Domain/Order/OrderCancelService.php b/backend/app/Services/Domain/Order/OrderCancelService.php index 0c0c904431..09b1f0ccf5 100644 --- a/backend/app/Services/Domain/Order/OrderCancelService.php +++ b/backend/app/Services/Domain/Order/OrderCancelService.php @@ -103,11 +103,17 @@ private function adjustProductQuantities(OrderDomainObject $order): void return $attendee->getStatus() === AttendeeStatus::ACTIVE->name; }); - $productIdCountMap = $attendees - ->map(fn(AttendeeDomainObject $attendee) => $attendee->getProductPriceId())->countBy(); - - foreach ($productIdCountMap as $productPriceId => $count) { - $this->productQuantityService->decreaseQuantitySold($productPriceId, $count); + $groupedCounts = $attendees + ->map(fn(AttendeeDomainObject $attendee) => $attendee->getProductPriceId() . '_' . $attendee->getEventOccurrenceId()) + ->countBy(); + + foreach ($groupedCounts as $compositeKey => $count) { + [$productPriceId, $eventOccurrenceId] = explode('_', (string) $compositeKey); + $this->productQuantityService->decreaseQuantitySold( + (int) $productPriceId, + $count, + $eventOccurrenceId ? (int) $eventOccurrenceId : null, + ); } } @@ -129,15 +135,19 @@ private function dispatchCapacityChangedEvents(OrderDomainObject $order): void 'order_id' => $order->getId(), ]); - $productIds = $attendees - ->map(fn(AttendeeDomainObject $attendee) => $attendee->getProductId()) - ->unique(); + $capacityScopes = $attendees + ->map(fn(AttendeeDomainObject $attendee) => [ + 'product_id' => $attendee->getProductId(), + 'event_occurrence_id' => $attendee->getEventOccurrenceId(), + ]) + ->unique(fn (array $scope) => $scope['product_id'].'-'.$scope['event_occurrence_id']); - foreach ($productIds as $productId) { + foreach ($capacityScopes as $scope) { event(new CapacityChangedEvent( eventId: $order->getEventId(), direction: CapacityChangeDirection::INCREASED, - productId: $productId, + productId: $scope['product_id'], + eventOccurrenceId: $scope['event_occurrence_id'], )); } } diff --git a/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php b/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php index 6df66a3587..7bea1f128d 100644 --- a/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php +++ b/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php @@ -6,13 +6,18 @@ use HiEvents\DomainObjects\CapacityAssignmentDomainObject; use HiEvents\DomainObjects\Enums\ProductPriceType; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; +use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract; use HiEvents\DomainObjects\Generated\PromoCodeDomainObjectAbstract; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\Helper\Currency; +use HiEvents\Repository\Eloquent\Value\OrderAndDirection; +use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; -use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; +use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; +use HiEvents\Services\Domain\EventOccurrence\OccurrencePurchaseEligibilityService; use HiEvents\Services\Domain\Product\AvailableProductQuantitiesFetchService; use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesDTO; use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesResponseDTO; @@ -26,34 +31,136 @@ class OrderCreateRequestValidationService private AvailableProductQuantitiesResponseDTO $availableProductQuantities; public function __construct( - readonly private ProductRepositoryInterface $productRepository, - readonly private PromoCodeRepositoryInterface $promoCodeRepository, - readonly private EventRepositoryInterface $eventRepository, + readonly private ProductRepositoryInterface $productRepository, + readonly private PromoCodeRepositoryInterface $promoCodeRepository, + readonly private EventRepositoryInterface $eventRepository, + readonly private EventOccurrenceRepositoryInterface $occurrenceRepository, readonly private AvailableProductQuantitiesFetchService $fetchAvailableProductQuantitiesService, - ) - { - } + readonly private OccurrencePurchaseEligibilityService $occurrenceEligibilityService, + ) {} /** * @throws ValidationException * @throws Exception */ - public function validateRequestData(int $eventId, array $data = []): void + public function validateRequestData(int $eventId, array $data = []): array { - $this->validateTypes($data); - $event = $this->eventRepository->findById($eventId); + $data = $this->normalizeOccurrenceIds($event, $data); + + $this->validateTypes($data); $this->validatePromoCode($eventId, $data); $this->validateProductSelection($data); + $this->validateOccurrence($eventId, $data); + // Event-wide snapshot is still needed for `validateOverallCapacity` + // (event-level capacity assignments). $this->availableProductQuantities = $this->fetchAvailableProductQuantitiesService ->getAvailableProductQuantities( $event->getId(), ignoreCache: true, ); - $this->validateOverallCapacity($data); - $this->validateProductDetails($event, $data); + $this->validateOverallCapacity($event, $data); + + // Re-fetch availability per occurrence so the product-quantity check + // matches what `CreateOrderHandler::validateProductAvailability` enforces + // — previously this used only the event-wide snapshot, letting the + // validator wave through orders the handler would later reject (causing + // confusing two-step failures during checkout). + $this->validateProductDetailsPerOccurrence($event, $data); + + return $data; + } + + private function normalizeOccurrenceIds(EventDomainObject $event, array $data): array + { + if ($event->isRecurring() || empty($data['products']) || ! is_array($data['products'])) { + return $data; + } + + $missingOccurrenceId = collect($data['products']) + ->contains(fn ($product): bool => is_array($product) && empty($product['event_occurrence_id'])); + + if (! $missingOccurrenceId) { + return $data; + } + + $occurrence = $this->getSingleEventOccurrence($event->getId()); + if ($occurrence === null) { + return $data; + } + + $data['products'] = collect($data['products']) + ->map(function ($product) use ($occurrence) { + if (! is_array($product)) { + return $product; + } + + if (empty($product['event_occurrence_id'])) { + $product['event_occurrence_id'] = $occurrence->getId(); + } + + return $product; + }) + ->all(); + + return $data; + } + + private function getSingleEventOccurrence(int $eventId): ?EventOccurrenceDomainObject + { + return $this->occurrenceRepository + ->findWhere( + where: [ + EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId, + ], + orderAndDirections: [ + new OrderAndDirection(EventOccurrenceDomainObjectAbstract::START_DATE, 'asc'), + ], + ) + ->first(); + } + + /** + * Walk each occurrence's product group and validate against availability + * scoped to that occurrence. For non-recurring events there's only one + * occurrence, so this is a single iteration with the same per-occurrence + * data the order handler uses. + */ + private function validateProductDetailsPerOccurrence(EventDomainObject $event, array $data): void + { + $eventWideAvailability = $this->availableProductQuantities; + $productsByOccurrence = collect($data['products'])->groupBy('event_occurrence_id'); + + try { + foreach ($productsByOccurrence as $occurrenceId => $products) { + $this->availableProductQuantities = $this->fetchAvailableProductQuantitiesService + ->getAvailableProductQuantities( + $event->getId(), + ignoreCache: true, + eventOccurrenceId: $occurrenceId !== null && $occurrenceId !== '' + ? (int) $occurrenceId + : null, + ); + + foreach ($products as $productAndQuantities) { + $allProducts = $this->getProducts(['products' => [$productAndQuantities]]); + $productIndex = collect($data['products'])->search( + fn ($p) => $p === $productAndQuantities, + ); + $this->validateSingleProductDetails( + $event, + is_int($productIndex) ? $productIndex : 0, + $productAndQuantities, + $allProducts, + ); + } + } + } finally { + // Restore so any subsequent caller sees the event-wide snapshot. + $this->availableProductQuantities = $eventWideAvailability; + } } /** @@ -67,7 +174,7 @@ private function validatePromoCode(int $eventId, array $data): void PromoCodeDomainObjectAbstract::EVENT_ID => $eventId, ]); - if (!$promoCode) { + if (! $promoCode) { throw ValidationException::withMessages([ 'promo_code' => __('This promo code is invalid'), ]); @@ -83,8 +190,15 @@ private function validateTypes(array $data): void $validator = Validator::make($data, [ 'products' => 'required|array', 'products.*.product_id' => 'required|integer', + 'products.*.event_occurrence_id' => 'required|integer', 'products.*.quantities' => 'required|array', - 'products.*.quantities.*.quantity' => 'required|integer', + // `min:0` blocks the mixed-tier exploit: without it, a single + // request with one tier at +2 and another at -1 sums to a positive + // selection but persists a negative order_item that distorts + // totals/stock and survives downstream availability checks + // (validateProductPricesQuantity only guards `quantity > available`, + // and `-1 > N` is false). + 'products.*.quantities.*.quantity' => 'required|integer|min:0', 'products.*.quantities.*.price_id' => 'required|integer', 'products.*.quantities.*.price' => 'numeric|min:0', ]); @@ -100,35 +214,54 @@ private function validateTypes(array $data): void private function validateProductSelection(array $data): void { $productData = collect($data['products']); - if ($productData->isEmpty() || $productData->sum(fn($product) => collect($product['quantities'])->sum('quantity')) === 0) { + if ($productData->isEmpty() || $productData->sum(fn ($product) => collect($product['quantities'])->sum('quantity')) === 0) { throw ValidationException::withMessages([ - 'products' => __('You haven\'t selected any products') + 'products' => __('You haven\'t selected any products'), ]); } } /** - * @throws Exception + * @throws ValidationException */ - private function getProducts(array $data): Collection + private function validateOccurrence(int $eventId, array $data): void { - $productIds = collect($data['products'])->pluck('product_id'); - return $this->productRepository - ->loadRelation(ProductPriceDomainObject::class) - ->findWhereIn('id', $productIds->toArray()); + $productsByOccurrence = collect($data['products'])->groupBy('event_occurrence_id'); + + foreach ($productsByOccurrence as $occurrenceId => $products) { + if ($occurrenceId === null || $occurrenceId === '') { + throw ValidationException::withMessages([ + 'event_occurrence_id' => __('An event occurrence must be specified'), + ]); + } + + $totalQuantityRequested = (int) $products + ->sum(fn ($product) => collect($product['quantities'])->sum('quantity')); + + $this->occurrenceEligibilityService->assertOccurrencePurchasable( + eventId: $eventId, + occurrenceId: (int) $occurrenceId, + additionalQuantity: $totalQuantityRequested, + ); + + $productIds = $products->pluck('product_id')->map(fn ($id) => (int) $id)->all(); + $this->occurrenceEligibilityService->assertProductsVisibleOnOccurrence( + (int) $occurrenceId, + $productIds, + ); + } } /** - * @throws ValidationException * @throws Exception */ - private function validateProductDetails(EventDomainObject $event, array $data): void + private function getProducts(array $data): Collection { - $products = $this->getProducts($data); + $productIds = collect($data['products'])->pluck('product_id'); - foreach ($data['products'] as $productIndex => $productAndQuantities) { - $this->validateSingleProductDetails($event, $productIndex, $productAndQuantities, $products); - } + return $this->productRepository + ->loadRelation(ProductPriceDomainObject::class) + ->findWhereIn('id', $productIds->toArray()); } /** @@ -144,8 +277,8 @@ private function validateSingleProductDetails(EventDomainObject $event, int $pro } /** @var ProductDomainObject $product */ - $product = $products->filter(fn($t) => $t->getId() === $productId)->first(); - if (!$product) { + $product = $products->filter(fn ($t) => $t->getId() === $productId)->first(); + if (! $product) { throw new NotFoundHttpException(sprintf('Product ID %d not found', $productId)); } @@ -187,22 +320,22 @@ private function validateSingleProductDetails(EventDomainObject $event, int $pro private function validateProductQuantity(int $productIndex, array $productAndQuantities, ProductDomainObject $product): void { $totalQuantity = collect($productAndQuantities['quantities'])->sum('quantity'); - $maxPerOrder = (int)$product->getMaxPerOrder() ?: 100; + $maxPerOrder = (int) $product->getMaxPerOrder() ?: 100; $capacityMaximum = $this->availableProductQuantities ->productQuantities ->where('product_id', $product->getId()) - ->map(fn(AvailableProductQuantitiesDTO $price) => $price->capacities) + ->map(fn (AvailableProductQuantitiesDTO $price) => $price->capacities) ->flatten() - ->min(fn(CapacityAssignmentDomainObject $capacity) => $capacity->getCapacity()); + ->min(fn (CapacityAssignmentDomainObject $capacity) => $capacity->getCapacity()); $productAvailableQuantity = $this->availableProductQuantities ->productQuantities - ->first(fn(AvailableProductQuantitiesDTO $price) => $price->product_id === $product->getId()) + ->first(fn (AvailableProductQuantitiesDTO $price) => $price->product_id === $product->getId()) ->quantity_available; - # if there are fewer products available than the configured minimum, we allow less than the minimum to be purchased - $minPerOrder = min((int)$product->getMinPerOrder() ?: 1, + // if there are fewer products available than the configured minimum, we allow less than the minimum to be purchased + $minPerOrder = min((int) $product->getMinPerOrder() ?: 1, $capacityMaximum ?: $maxPerOrder, $productAvailableQuantity ?: $maxPerOrder); @@ -214,7 +347,7 @@ private function validateProductQuantity(int $productIndex, array $productAndQua if ($totalQuantity > $maxPerOrder) { throw ValidationException::withMessages([ - "products.$productIndex" => __("The maximum number of products available for :products is :max", [ + "products.$productIndex" => __('The maximum number of products available for :products is :max', [ 'max' => $maxPerOrder, 'product' => $product->getTitle(), ]), @@ -223,7 +356,7 @@ private function validateProductQuantity(int $productIndex, array $productAndQua if ($totalQuantity < $minPerOrder) { throw ValidationException::withMessages([ - "products.$productIndex" => __("You must order at least :min products for :product", [ + "products.$productIndex" => __('You must order at least :min products for :product', [ 'min' => $minPerOrder, 'product' => $product->getTitle(), ]), @@ -242,18 +375,17 @@ private function validateProductEvent(EventDomainObject $event, int $productId, * @throws ValidationException */ private function validateProductTypeAndPrice( - EventDomainObject $event, - int $productIndex, - array $productAndQuantities, + EventDomainObject $event, + int $productIndex, + array $productAndQuantities, ProductDomainObject $product - ): void - { + ): void { if ($product->getType() === ProductPriceType::DONATION->name) { $price = $productAndQuantities['quantities'][0]['price'] ?? 0; if ($price < $product->getPrice()) { $formattedPrice = Currency::format($product->getPrice(), $event->getCurrency()); throw ValidationException::withMessages([ - "products.$productIndex.quantities.0.price" => __("The minimum amount is :price", ['price' => $formattedPrice]), + "products.$productIndex.quantities.0.price" => __('The minimum amount is :price', ['price' => $formattedPrice]), ]); } } @@ -266,7 +398,7 @@ private function validateSoldOutProducts(int $productId, int $productIndex, Prod { if ($product->isSoldOut()) { throw ValidationException::withMessages([ - "products.$productIndex" => __("The product :product is sold out", [ + "products.$productIndex" => __('The product :product is sold out', [ 'id' => $productId, 'product' => $product->getTitle(), ]), @@ -285,20 +417,20 @@ private function validatePriceIdAndQuantity(int $productIndex, array $productAnd $priceId = $quantityData['price_id'] ?? null; $quantity = $quantityData['quantity'] ?? null; - if (null === $priceId || null === $quantity) { - $missingField = null === $priceId ? 'price_id' : 'quantity'; - $errors["products.$productIndex.quantities.$quantityIndex.$missingField"] = __(":field must be specified", [ - 'field' => ucfirst($missingField) + if ($priceId === null || $quantity === null) { + $missingField = $priceId === null ? 'price_id' : 'quantity'; + $errors["products.$productIndex.quantities.$quantityIndex.$missingField"] = __(':field must be specified', [ + 'field' => ucfirst($missingField), ]); } - $validPriceIds = $product->getProductPrices()?->map(fn(ProductPriceDomainObject $price) => $price->getId()); - if (!in_array($priceId, $validPriceIds->toArray(), true)) { + $validPriceIds = $product->getProductPrices()?->map(fn (ProductPriceDomainObject $price) => $price->getId()); + if (! in_array($priceId, $validPriceIds->toArray(), true)) { $errors["products.$productIndex.quantities.$quantityIndex.price_id"] = __('Invalid price ID'); } } - if (!empty($errors)) { + if (! empty($errors)) { throw ValidationException::withMessages($errors); } } @@ -321,21 +453,21 @@ private function validateProductPricesQuantity(array $quantities, ProductDomainO /** @var ProductPriceDomainObject $productPrice */ $productPrice = $product->getProductPrices() - ?->first(fn(ProductPriceDomainObject $price) => $price->getId() === $productQuantity['price_id']); + ?->first(fn (ProductPriceDomainObject $price) => $price->getId() === $productQuantity['price_id']); if ($productQuantity['quantity'] > $numberAvailable) { if ($numberAvailable === 0) { throw ValidationException::withMessages([ - "products.$productIndex" => __("The product :product is sold out", [ - 'product' => $product->getTitle() . ($productPrice->getLabel() ? ' - ' . $productPrice->getLabel() : ''), + "products.$productIndex" => __('The product :product is sold out', [ + 'product' => $product->getTitle().($productPrice->getLabel() ? ' - '.$productPrice->getLabel() : ''), ]), ]); } throw ValidationException::withMessages([ - "products.$productIndex" => __("The maximum number of products available for :product is :max", [ + "products.$productIndex" => __('The maximum number of products available for :product is :max', [ 'max' => $numberAvailable, - 'product' => $product->getTitle() . ($productPrice->getLabel() ? ' - ' . $productPrice->getLabel() : ''), + 'product' => $product->getTitle().($productPrice->getLabel() ? ' - '.$productPrice->getLabel() : ''), ]), ]); } @@ -345,20 +477,29 @@ private function validateProductPricesQuantity(array $quantities, ProductDomainO /** * @throws ValidationException */ - private function validateOverallCapacity(array $data): void + private function validateOverallCapacity(EventDomainObject $event, array $data): void { + // Capacity assignments are deliberately not enforced for recurring + // events — capacity is modelled per-occurrence on the recurrence side + // and the assignment itself spans every session, which makes a single + // shared total meaningless for a series. Per-occurrence capacity is + // still validated in OccurrencePurchaseEligibilityService. + if ($event->isRecurring()) { + return; + } + foreach ($this->availableProductQuantities->capacities as $capacity) { if ($capacity->getProducts() === null) { continue; } - $productIds = $capacity->getProducts()->map(fn(ProductDomainObject $product) => $product->getId()); + $productIds = $capacity->getProducts()->map(fn (ProductDomainObject $product) => $product->getId()); $totalQuantity = collect($data['products']) - ->filter(fn($product) => in_array($product['product_id'], $productIds->toArray(), true)) - ->sum(fn($product) => collect($product['quantities'])->sum('quantity')); + ->filter(fn ($product) => in_array($product['product_id'], $productIds->toArray(), true)) + ->sum(fn ($product) => collect($product['quantities'])->sum('quantity')); $reservedProductQuantities = $capacity->getProducts() - ->map(fn(ProductDomainObject $product) => $this + ->map(fn (ProductDomainObject $product) => $this ->availableProductQuantities ->productQuantities ->where('product_id', $product->getId()) diff --git a/backend/app/Services/Domain/Order/OrderItemProcessingService.php b/backend/app/Services/Domain/Order/OrderItemProcessingService.php index f34ccd50ff..bb8b122f47 100644 --- a/backend/app/Services/Domain/Order/OrderItemProcessingService.php +++ b/backend/app/Services/Domain/Order/OrderItemProcessingService.php @@ -2,19 +2,19 @@ namespace HiEvents\Services\Domain\Order; -use HiEvents\DomainObjects\AccountConfigurationDomainObject; use HiEvents\DomainObjects\Enums\TaxCalculationType; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract; use HiEvents\DomainObjects\OrderDomainObject; +use HiEvents\DomainObjects\OrganizerConfigurationDomainObject; +use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\DomainObjects\PromoCodeDomainObject; use HiEvents\DomainObjects\TaxAndFeesDomainObject; use HiEvents\Helper\Currency; use HiEvents\Repository\Eloquent\Value\Relationship; -use HiEvents\Repository\Interfaces\AccountRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; @@ -27,7 +27,7 @@ class OrderItemProcessingService { - private ?AccountConfigurationDomainObject $accountConfiguration = null; + private ?OrganizerConfigurationDomainObject $organizerConfiguration = null; private ?EventSettingDomainObject $eventSettings = null; public function __construct( @@ -36,7 +36,6 @@ public function __construct( private readonly TaxAndFeeCalculationService $taxCalculationService, private readonly ProductPriceService $productPriceService, private readonly OrderPlatformFeePassThroughService $platformFeeService, - private readonly AccountRepositoryInterface $accountRepository, private readonly EventRepositoryInterface $eventRepository, ) { @@ -53,7 +52,7 @@ public function process( OrderDomainObject $order, Collection $productsOrderDetails, EventDomainObject $event, - ?PromoCodeDomainObject $promoCode + ?PromoCodeDomainObject $promoCode, ): Collection { $this->loadPlatformFeeConfiguration($event->getId()); @@ -75,11 +74,13 @@ public function process( ); } - $productOrderDetail->quantities->each(function (OrderProductPriceDTO $productPrice) use ($promoCode, $order, $orderItems, $product, $event) { + $eventOccurrenceId = $productOrderDetail->event_occurrence_id; + + $productOrderDetail->quantities->each(function (OrderProductPriceDTO $productPrice) use ($promoCode, $order, $orderItems, $product, $event, $eventOccurrenceId) { if ($productPrice->quantity === 0) { return; } - $orderItemData = $this->calculateOrderItemData($product, $productPrice, $order, $promoCode, $event->getCurrency()); + $orderItemData = $this->calculateOrderItemData($product, $productPrice, $order, $promoCode, $event->getCurrency(), $eventOccurrenceId); $orderItems->push($this->orderRepository->addOrderItem($orderItemData)); }); } @@ -89,20 +90,22 @@ public function process( private function loadPlatformFeeConfiguration(int $eventId): void { - $account = $this->accountRepository - ->loadRelation(new Relationship( - domainObject: AccountConfigurationDomainObject::class, - name: 'configuration', - )) - ->findByEventId($eventId); - - $this->accountConfiguration = $account->getConfiguration(); - $event = $this->eventRepository ->loadRelation(EventSettingDomainObject::class) + ->loadRelation(new Relationship( + domainObject: OrganizerDomainObject::class, + nested: [ + new Relationship( + domainObject: OrganizerConfigurationDomainObject::class, + name: 'organizer_configuration', + ), + ], + name: 'organizer', + )) ->findById($eventId); $this->eventSettings = $event->getEventSettings(); + $this->organizerConfiguration = $event->getOrganizer()?->getOrganizerConfiguration(); } private function calculateOrderItemData( @@ -110,10 +113,11 @@ private function calculateOrderItemData( OrderProductPriceDTO $productPriceDetails, OrderDomainObject $order, ?PromoCodeDomainObject $promoCode, - string $currency + string $currency, + ?int $eventOccurrenceId = null, ): array { - $prices = $this->productPriceService->getPrice($product, $productPriceDetails, $promoCode); + $prices = $this->productPriceService->getPrice($product, $productPriceDetails, $promoCode, $eventOccurrenceId); $priceWithDiscount = $prices->price; $priceBeforeDiscount = $prices->price_before_discount; @@ -156,17 +160,18 @@ private function calculateOrderItemData( 'total_service_fee' => $totalFee, 'total_gross' => $totalGross, 'taxes_and_fees_rollup' => $rollUp, + 'event_occurrence_id' => $eventOccurrenceId, ]; } private function calculatePlatformFee(float $total, int $quantity, string $currency): float { - if ($this->accountConfiguration === null || $this->eventSettings === null) { + if ($this->organizerConfiguration === null || $this->eventSettings === null) { return 0.0; } return $this->platformFeeService->calculatePlatformFee( - $this->accountConfiguration, + $this->organizerConfiguration, $this->eventSettings, $total, $quantity, diff --git a/backend/app/Services/Domain/Order/OrderPlatformFeePassThroughService.php b/backend/app/Services/Domain/Order/OrderPlatformFeePassThroughService.php index 7f87def40b..df85a228b6 100644 --- a/backend/app/Services/Domain/Order/OrderPlatformFeePassThroughService.php +++ b/backend/app/Services/Domain/Order/OrderPlatformFeePassThroughService.php @@ -3,7 +3,7 @@ namespace HiEvents\Services\Domain\Order; use Brick\Money\Currency as BrickCurrency; -use HiEvents\DomainObjects\AccountConfigurationDomainObject; +use HiEvents\DomainObjects\OrganizerConfigurationDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\Helper\Currency; use HiEvents\Services\Infrastructure\CurrencyConversion\CurrencyConversionClientInterface; @@ -45,7 +45,7 @@ public function isEnabled(EventSettingDomainObject $eventSettings): bool * In other words: application_fee(total + P) = P */ public function calculatePlatformFee( - AccountConfigurationDomainObject $accountConfiguration, + OrganizerConfigurationDomainObject $organizerConfiguration, EventSettingDomainObject $eventSettings, float $total, int $quantity, @@ -56,8 +56,8 @@ public function calculatePlatformFee( return 0.0; } - $fixedFee = $this->getConvertedFixedFee($accountConfiguration, $currency); - $percentageRate = $accountConfiguration->getPercentageApplicationFee() / 100; + $fixedFee = $this->getConvertedFixedFee($organizerConfiguration, $currency); + $percentageRate = $organizerConfiguration->getPercentageApplicationFee() / 100; if ($percentageRate >= 1) { return Currency::round(($fixedFee * $quantity) + ($total * $percentageRate)); @@ -70,12 +70,12 @@ public function calculatePlatformFee( } private function getConvertedFixedFee( - AccountConfigurationDomainObject $accountConfiguration, + OrganizerConfigurationDomainObject $organizerConfiguration, string $currency ): float { - $baseFee = $accountConfiguration->getFixedApplicationFee(); - $baseCurrency = $accountConfiguration->getApplicationFeeCurrency(); + $baseFee = $organizerConfiguration->getFixedApplicationFee(); + $baseCurrency = $organizerConfiguration->getApplicationFeeCurrency(); if ($currency === $baseCurrency) { return $baseFee; diff --git a/backend/app/Services/Domain/Order/Vat/VatRateDeterminationService.php b/backend/app/Services/Domain/Order/Vat/VatRateDeterminationService.php index 22b5213273..f75a2401c2 100644 --- a/backend/app/Services/Domain/Order/Vat/VatRateDeterminationService.php +++ b/backend/app/Services/Domain/Order/Vat/VatRateDeterminationService.php @@ -2,8 +2,8 @@ namespace HiEvents\Services\Domain\Order\Vat; -use HiEvents\DomainObjects\AccountVatSettingDomainObject; use HiEvents\DomainObjects\Enums\CountryCode; +use HiEvents\DomainObjects\OrganizerVatSettingDomainObject; use Illuminate\Config\Repository; use ValueError; @@ -21,7 +21,7 @@ public function __construct( $this->defaultVatCountry = $this->config->get('app.tax.default_vat_country', CountryCode::IE->value); } - public function determineVatRatePercentage(AccountVatSettingDomainObject $vatSetting): float + public function determineVatRatePercentage(OrganizerVatSettingDomainObject $vatSetting): float { $country = $vatSetting->getVatCountryCode(); diff --git a/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentRequestDTO.php b/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentRequestDTO.php index d6fdb9ea3e..5536db99cd 100644 --- a/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentRequestDTO.php +++ b/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentRequestDTO.php @@ -4,20 +4,22 @@ use HiEvents\DataTransferObjects\BaseDTO; use HiEvents\DomainObjects\AccountDomainObject; -use HiEvents\DomainObjects\AccountVatSettingDomainObject; use HiEvents\DomainObjects\OrderDomainObject; +use HiEvents\DomainObjects\OrganizerConfigurationDomainObject; +use HiEvents\DomainObjects\OrganizerVatSettingDomainObject; use HiEvents\Values\MoneyValue; class CreatePaymentIntentRequestDTO extends BaseDTO { public function __construct( - public readonly MoneyValue $amount, - public readonly string $currencyCode, - public readonly AccountDomainObject $account, - public readonly OrderDomainObject $order, - public readonly ?string $stripeAccountId = null, - public readonly ?AccountVatSettingDomainObject $vatSettings = null, - public readonly ?string $description = null, + public readonly MoneyValue $amount, + public readonly string $currencyCode, + public readonly AccountDomainObject $account, + public readonly OrderDomainObject $order, + public readonly ?OrganizerConfigurationDomainObject $configuration = null, + public readonly ?string $stripeAccountId = null, + public readonly ?OrganizerVatSettingDomainObject $vatSettings = null, + public readonly ?string $description = null, ) { } diff --git a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/AccountUpdateHandler.php b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/AccountUpdateHandler.php index 98fbfaa1b8..6d4a6efbb7 100644 --- a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/AccountUpdateHandler.php +++ b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/AccountUpdateHandler.php @@ -2,35 +2,19 @@ namespace HiEvents\Services\Domain\Payment\Stripe\EventHandlers; -use HiEvents\DomainObjects\AccountStripePlatformDomainObject; -use HiEvents\DomainObjects\Generated\AccountStripePlatformDomainObjectAbstract; -use HiEvents\Repository\Interfaces\AccountStripePlatformRepositoryInterface; use HiEvents\Services\Domain\Payment\Stripe\StripeAccountSyncService; use Stripe\Account; -use Symfony\Component\Routing\Exception\ResourceNotFoundException; class AccountUpdateHandler { public function __construct( - private readonly AccountStripePlatformRepositoryInterface $accountStripePlatformRepository, - private readonly StripeAccountSyncService $stripeAccountSyncService, + private readonly StripeAccountSyncService $stripeAccountSyncService, ) { } public function handleEvent(Account $stripeAccount): void { - /** @var AccountStripePlatformDomainObject $accountStripePlatform */ - $accountStripePlatform = $this->accountStripePlatformRepository->findFirstWhere([ - AccountStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id, - ]); - - if ($accountStripePlatform === null) { - throw new ResourceNotFoundException( - sprintf('Account stripe platform with stripe account id %s not found', $stripeAccount->id) - ); - } - - $this->stripeAccountSyncService->syncStripeAccountStatus($accountStripePlatform, $stripeAccount); + $this->stripeAccountSyncService->syncStripeAccountStatusByAccountId($stripeAccount); } } diff --git a/backend/app/Services/Domain/Payment/Stripe/StripeAccountSyncService.php b/backend/app/Services/Domain/Payment/Stripe/StripeAccountSyncService.php index 47c4881700..89be87154b 100644 --- a/backend/app/Services/Domain/Payment/Stripe/StripeAccountSyncService.php +++ b/backend/app/Services/Domain/Payment/Stripe/StripeAccountSyncService.php @@ -2,14 +2,15 @@ namespace HiEvents\Services\Domain\Payment\Stripe; -use HiEvents\DomainObjects\AccountStripePlatformDomainObject; use HiEvents\DomainObjects\Enums\CountryCode; -use HiEvents\DomainObjects\Generated\AccountStripePlatformDomainObjectAbstract; -use HiEvents\DomainObjects\Generated\AccountVatSettingDomainObjectAbstract; +use HiEvents\DomainObjects\Generated\OrganizerStripePlatformDomainObjectAbstract; +use HiEvents\DomainObjects\Generated\OrganizerVatSettingDomainObjectAbstract; +use HiEvents\DomainObjects\OrganizerStripePlatformDomainObject; use HiEvents\Helper\Url; use HiEvents\Repository\Interfaces\AccountRepositoryInterface; -use HiEvents\Repository\Interfaces\AccountStripePlatformRepositoryInterface; -use HiEvents\Repository\Interfaces\AccountVatSettingRepositoryInterface; +use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; +use HiEvents\Repository\Interfaces\OrganizerStripePlatformRepositoryInterface; +use HiEvents\Repository\Interfaces\OrganizerVatSettingRepositoryInterface; use Illuminate\Config\Repository; use Psr\Log\LoggerInterface; use Stripe\Account; @@ -19,95 +20,185 @@ class StripeAccountSyncService { public function __construct( - private readonly LoggerInterface $logger, - private readonly AccountRepositoryInterface $accountRepository, - private readonly AccountStripePlatformRepositoryInterface $accountStripePlatformRepository, - private readonly AccountVatSettingRepositoryInterface $vatSettingRepository, - private readonly Repository $config, + private readonly LoggerInterface $logger, + private readonly AccountRepositoryInterface $accountRepository, + private readonly OrganizerRepositoryInterface $organizerRepository, + private readonly OrganizerStripePlatformRepositoryInterface $organizerStripePlatformRepository, + private readonly OrganizerVatSettingRepositoryInterface $vatSettingRepository, + private readonly Repository $config, ) { } - /** - * Sync Stripe account status and details to our database - */ - public function syncStripeAccountStatus( - AccountStripePlatformDomainObject $accountStripePlatform, - Account $stripeAccount - ): void + public function isStripeAccountComplete(Account $stripeAccount): bool { - $isAccountSetupCompleted = $this->isStripeAccountComplete($stripeAccount); - $isCurrentlyComplete = $accountStripePlatform->getStripeSetupCompletedAt() !== null; + return $stripeAccount->charges_enabled && $stripeAccount->payouts_enabled; + } - // Only update if status has actually changed - if ($isCurrentlyComplete === $isAccountSetupCompleted) { - // Still update account details even if status hasn't changed - $this->updateAccountDetails($stripeAccount); - return; - } + public function createStripeAccountSetupUrl(Account $stripeAccount, StripeClient $stripeClient, int $organizerId): ?string + { + try { + $refreshUrl = sprintf(Url::getFrontEndUrlFromConfig(Url::STRIPE_CONNECT_REFRESH_URL), $organizerId); + $returnUrl = sprintf(Url::getFrontEndUrlFromConfig(Url::STRIPE_CONNECT_RETURN_URL), $organizerId); + + $accountLink = $stripeClient->accountLinks->create([ + 'account' => $stripeAccount->id, + 'refresh_url' => $this->appendQueryParam($refreshUrl, 'is_refresh=1'), + 'return_url' => $this->appendQueryParam($returnUrl, 'is_return=1'), + 'type' => 'account_onboarding', + ]); - if ($isAccountSetupCompleted) { - $this->markAccountAsComplete($accountStripePlatform, $stripeAccount); - } else { - $this->logger->info(sprintf( - 'Stripe Connect account is no longer complete. Updating account stripe platform %s', - $stripeAccount->id - )); - $this->updateAccountStatusAndDetails($stripeAccount, isAccountSetupCompleted: false); - $this->updateAccountDetails($stripeAccount); + return $accountLink->url; + } catch (Throwable $e) { + $this->logger->error('Failed to create Stripe Connect Account Link', [ + 'stripe_account_id' => $stripeAccount->id, + 'organizer_id' => $organizerId, + 'error' => $e->getMessage(), + ]); + return null; } } /** - * Force update account status when we know it should be complete - * (e.g., from GetStripeConnectAccountsHandler when Stripe says complete but DB doesn't) - * @throws NoStripeCountryCodeException + * Insert the query param BEFORE the URL's fragment so the resulting URL is well-formed. + * Naive concatenation breaks when the configured return URL ends with #fragment + * (e.g. /manage/organizer/%d/settings#payouts) — the param lands inside the hash. */ - public function markAccountAsComplete( - AccountStripePlatformDomainObject $accountStripePlatform, - Account $stripeAccount - ): void + private function appendQueryParam(string $url, string $param): string { - $this->logger->info(sprintf( - 'Marking Stripe Connect account as complete for account stripe platform %s with Stripe account ID %s', - $accountStripePlatform->getId(), - $stripeAccount->id - )); + $hashPosition = strpos($url, '#'); + $base = $hashPosition === false ? $url : substr($url, 0, $hashPosition); + $fragment = $hashPosition === false ? '' : substr($url, $hashPosition); + $separator = str_contains($base, '?') ? '&' : '?'; - $this->updateAccountStatusAndDetails($stripeAccount, isAccountSetupCompleted: true); - $this->updateAccountCountryAndVerificationStatus($accountStripePlatform, $stripeAccount); - $this->createVatSettingIfMissing($accountStripePlatform); + return $base . $separator . $param . $fragment; } - public function isStripeAccountComplete(Account $stripeAccount): bool + /** + * Webhook entrypoint — updates every organizer row sharing this Stripe account + * and seeds account-level country/VAT state for every organizer that owns one. + */ + public function syncStripeAccountStatusByAccountId(Account $stripeAccount): void { - return $stripeAccount->charges_enabled && $stripeAccount->payouts_enabled; + $details = $this->buildAccountDetails($stripeAccount); + $isAccountSetupCompleted = $this->isStripeAccountComplete($stripeAccount); + + $this->organizerStripePlatformRepository->updateWhere( + attributes: [ + OrganizerStripePlatformDomainObjectAbstract::STRIPE_SETUP_COMPLETED_AT => $isAccountSetupCompleted ? now() : null, + OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_DETAILS => $details, + ], + where: [ + OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id, + ] + ); + + if (!$isAccountSetupCompleted) { + return; + } + + $organizerRows = $this->organizerStripePlatformRepository->findWhere([ + OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id, + ]); + + foreach ($organizerRows as $organizerRow) { + $this->updateOrganizerCountryAndVerificationStatus($organizerRow, $stripeAccount); + $this->seedVatSettingForOrganizerIfMissing( + organizerId: $organizerRow->getOrganizerId(), + countryCode: $stripeAccount->country, + stripeAccountId: $stripeAccount->id, + organizerStripePlatformId: $organizerRow->getId(), + ); + } } - private function updateAccountStatusAndDetails( - Account $stripeAccount, - bool $isAccountSetupCompleted + public function markAccountAsCompleteForOrganizer( + OrganizerStripePlatformDomainObject $organizerStripePlatform, + Account $stripeAccount, ): void { - $this->accountStripePlatformRepository->updateWhere( + $this->logger->info(sprintf( + 'Marking Stripe Connect account as complete for organizer stripe platform %s with Stripe account ID %s', + $organizerStripePlatform->getId(), + $stripeAccount->id, + )); + + $this->organizerStripePlatformRepository->updateWhere( attributes: [ - AccountStripePlatformDomainObjectAbstract::STRIPE_SETUP_COMPLETED_AT => $isAccountSetupCompleted ? now() : null, - AccountStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_DETAILS => $this->buildAccountDetails($stripeAccount), + OrganizerStripePlatformDomainObjectAbstract::STRIPE_SETUP_COMPLETED_AT => now(), + OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_DETAILS => $this->buildAccountDetails($stripeAccount), ], where: [ - AccountStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id, + OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id, ] ); + + $this->updateOrganizerCountryAndVerificationStatus($organizerStripePlatform, $stripeAccount); + $this->seedVatSettingForOrganizerIfMissing( + organizerId: $organizerStripePlatform->getOrganizerId(), + countryCode: $stripeAccount->country, + stripeAccountId: $stripeAccount->id, + organizerStripePlatformId: $organizerStripePlatform->getId(), + ); } - private function updateAccountDetails(Account $stripeAccount): void + /** + * Seeds an empty organizer VAT setting row when an organizer first connects + * (or copies a connection) to a Stripe account in an EU country. + * Public so the copy/reuse flow can call it with a country code parsed + * from cached stripe_account_details rather than a live Stripe Account. + */ + public function seedVatSettingForOrganizerIfMissing( + int $organizerId, + ?string $countryCode, + ?string $stripeAccountId = null, + ?int $organizerStripePlatformId = null, + ): void { - $this->accountStripePlatformRepository->updateWhere( + if (!$this->config->get('app.saas_mode_enabled')) { + return; + } + + if ($this->config->get('app.tax.eu_vat_handling_enabled') !== true) { + return; + } + + if (!$countryCode) { + $this->logger->error('Stripe account country code is missing, cannot create VAT setting.', [ + 'organizer_id' => $organizerId, + 'organizer_stripe_platform_id' => $organizerStripePlatformId, + 'stripe_account_id' => $stripeAccountId, + ]); + return; + } + + $countryCode = strtoupper($countryCode); + if (!CountryCode::isEuCountry(CountryCode::from($countryCode))) { + return; + } + + $existingVatSetting = $this->vatSettingRepository->findByOrganizerId($organizerId); + + if ($existingVatSetting === null) { + $this->vatSettingRepository->create([ + OrganizerVatSettingDomainObjectAbstract::ORGANIZER_ID => $organizerId, + OrganizerVatSettingDomainObjectAbstract::VAT_VALIDATED => false, + OrganizerVatSettingDomainObjectAbstract::VAT_COUNTRY_CODE => $countryCode, + ]); + } + } + + public function syncStripeAccountDetailsForOrganizer( + OrganizerStripePlatformDomainObject $organizerStripePlatform, + Account $stripeAccount, + ): void + { + $this->organizerStripePlatformRepository->updateWhere( attributes: [ - AccountStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_DETAILS => $this->buildAccountDetails($stripeAccount), + OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_DETAILS => $this->buildAccountDetails($stripeAccount), ], where: [ - AccountStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id, + OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id, ] ); } @@ -134,36 +225,20 @@ private function buildAccountDetails(Account $stripeAccount): string ], JSON_THROW_ON_ERROR); } - public function createStripeAccountSetupUrl(Account $stripeAccount, StripeClient $stripeClient): ?string + private function updateOrganizerCountryAndVerificationStatus( + OrganizerStripePlatformDomainObject $organizerStripePlatform, + Account $stripeAccount, + ): void { - try { - $accountLink = $stripeClient->accountLinks->create([ - 'account' => $stripeAccount->id, - 'refresh_url' => Url::getFrontEndUrlFromConfig(Url::STRIPE_CONNECT_REFRESH_URL, [ - 'is_refresh' => true, - ]), - 'return_url' => Url::getFrontEndUrlFromConfig(Url::STRIPE_CONNECT_RETURN_URL, [ - 'is_return' => true, - ]), - 'type' => 'account_onboarding', - ]); - - return $accountLink->url; - } catch (Throwable $e) { - $this->logger->error('Failed to create Stripe Connect Account Link', [ - 'stripe_account_id' => $stripeAccount->id, - 'error' => $e->getMessage(), - ]); - return null; + $organizer = $this->organizerRepository->findById($organizerStripePlatform->getOrganizerId()); + if ($organizer === null) { + return; } - } - private function updateAccountCountryAndVerificationStatus( - AccountStripePlatformDomainObject $accountStripePlatform, - Account $stripeAccount, - ): void - { - $account = $this->accountRepository->findById($accountStripePlatform->getAccountId()); + $account = $this->accountRepository->findById($organizer->getAccountId()); + if ($account === null) { + return; + } $updates = []; if (!$account->getCountry()) { @@ -178,58 +253,10 @@ private function updateAccountCountryAndVerificationStatus( $this->accountRepository->updateWhere( attributes: $updates, where: [ - 'id' => $accountStripePlatform->getAccountId(), + 'id' => $account->getId(), ] ); } } - /** - * @throws NoStripeCountryCodeException - */ - private function createVatSettingIfMissing(AccountStripePlatformDomainObject $accountStripePlatform): void - { - if ($this->config->get('app.tax.eu_vat_handling_enabled') !== true) { - $this->logger->info('EU VAT handling is disabled, skipping VAT setting creation.', [ - 'account_stripe_platform_id' => $accountStripePlatform->getId(), - 'account_id' => $accountStripePlatform->getAccountId(), - ]); - return; - } - - $countryCode = $accountStripePlatform->getStripeAccountDetails()['country']; - - if ($countryCode === null) { - $this->logger->error('Stripe account country code is missing, cannot create VAT setting.', [ - 'account_stripe_platform_id' => $accountStripePlatform->getId(), - 'account_id' => $accountStripePlatform->getAccountId(), - ]); - - throw new NoStripeCountryCodeException('Stripe account country code is missing. cannot create VAT setting.', - accountStripePlatformId: $accountStripePlatform->getId(), - accountId: $accountStripePlatform->getAccountId() - ); - } - - if (!CountryCode::isEuCountry(CountryCode::from($countryCode))) { - $this->logger->info('Account is not in an EU country, skipping VAT setting creation.', [ - 'account_stripe_platform_id' => $accountStripePlatform->getId(), - 'account_id' => $accountStripePlatform->getAccountId(), - 'country_code' => $countryCode, - ]); - return; - } - - $existingVatSetting = $this->vatSettingRepository->findFirstWhere([ - AccountVatSettingDomainObjectAbstract::ACCOUNT_ID => $accountStripePlatform->getAccountId(), - ]); - - if ($existingVatSetting === null) { - $this->vatSettingRepository->create([ - AccountVatSettingDomainObjectAbstract::ACCOUNT_ID => $accountStripePlatform->getAccountId(), - AccountVatSettingDomainObjectAbstract::VAT_VALIDATED => false, - AccountVatSettingDomainObjectAbstract::VAT_COUNTRY_CODE => $countryCode, - ]); - } - } } diff --git a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php index 74351891d0..9c043b4047 100644 --- a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php +++ b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php @@ -66,14 +66,16 @@ public function createPaymentIntentWithClient( try { $this->databaseManager->beginTransaction(); - $accountConfiguration = $paymentIntentDTO->account->getConfiguration(); - $bypassApplicationFees = $accountConfiguration?->getBypassApplicationFees() ?? false; - - $applicationFee = $this->orderApplicationFeeCalculationService->calculateApplicationFee( - accountConfiguration: $accountConfiguration, - order: $paymentIntentDTO->order, - vatSettings: $paymentIntentDTO->vatSettings, - ); + $configuration = $paymentIntentDTO->configuration; + $bypassApplicationFees = $configuration?->getBypassApplicationFees() ?? false; + + $applicationFee = $configuration + ? $this->orderApplicationFeeCalculationService->calculateApplicationFee( + configuration: $configuration, + order: $paymentIntentDTO->order, + vatSettings: $paymentIntentDTO->vatSettings, + ) + : null; $paymentIntent = $stripeClient->paymentIntents->create([ 'amount' => $paymentIntentDTO->amount->toMinorUnit(), diff --git a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php index 23946a6764..609b13a9af 100644 --- a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php +++ b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php @@ -14,28 +14,41 @@ class StripePaymentIntentRefundService { public function __construct( - private readonly Repository $config, - ) - { - } + private readonly Repository $config, + ) {} /** * @throws ApiErrorException * @throws MathException + * * @todo - catch and handle stripe errors */ public function refundPayment( - MoneyValue $amount, + MoneyValue $amount, StripePaymentDomainObject $payment, - StripeClient $stripeClient, - ): Refund - { + StripeClient $stripeClient, + ): Refund { + // Stable idempotency key prevents a duplicate refund when Stripe processes + // the call but the response is lost (network timeout, worker crash). A + // retry with the same params hits Stripe's idempotency cache; a partial + // refund for a different amount differs in the key and is allowed. + $opts = array_merge( + $this->getStripeAccountData($payment), + [ + 'idempotency_key' => sprintf( + 'refund_payment_%d_amount_%d', + $payment->getId(), + $amount->toMinorUnit(), + ), + ], + ); + return $stripeClient->refunds->create( params: [ 'payment_intent' => $payment->getPaymentIntentId(), - 'amount' => $amount->toMinorUnit() + 'amount' => $amount->toMinorUnit(), ], - opts: $this->getStripeAccountData($payment), + opts: $opts, ); } diff --git a/backend/app/Services/Domain/Product/AvailableProductQuantitiesFetchService.php b/backend/app/Services/Domain/Product/AvailableProductQuantitiesFetchService.php index d9c8869ea3..d5398200d7 100644 --- a/backend/app/Services/Domain/Product/AvailableProductQuantitiesFetchService.php +++ b/backend/app/Services/Domain/Product/AvailableProductQuantitiesFetchService.php @@ -5,10 +5,12 @@ use HiEvents\Constants; use HiEvents\DomainObjects\CapacityAssignmentDomainObject; use HiEvents\DomainObjects\Enums\CapacityAssignmentAppliesTo; +use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\Status\CapacityAssignmentStatus; use HiEvents\DomainObjects\Status\OrderStatus; -use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\Repository\Interfaces\CapacityAssignmentRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; +use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesDTO; use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesResponseDTO; use Illuminate\Config\Repository as Config; @@ -19,34 +21,53 @@ class AvailableProductQuantitiesFetchService { public function __construct( - private readonly DatabaseManager $db, - private readonly Config $config, - private readonly Cache $cache, + private readonly DatabaseManager $db, + private readonly Config $config, + private readonly Cache $cache, private readonly CapacityAssignmentRepositoryInterface $capacityAssignmentRepository, - ) - { - } - - public function getAvailableProductQuantities(int $eventId, bool $ignoreCache = false): AvailableProductQuantitiesResponseDTO - { - if (!$ignoreCache && $this->config->get('app.homepage_product_quantities_cache_ttl')) { + private readonly EventRepositoryInterface $eventRepository, + private readonly EventOccurrenceRepositoryInterface $occurrenceRepository, + ) {} + + public function getAvailableProductQuantities( + int $eventId, + bool $ignoreCache = false, + ?int $eventOccurrenceId = null, + ): AvailableProductQuantitiesResponseDTO { + if (! $ignoreCache && $eventOccurrenceId === null && $this->config->get('app.homepage_product_quantities_cache_ttl')) { $cachedData = $this->getDataFromCache($eventId); if ($cachedData) { return $cachedData; } } - $capacities = $this->capacityAssignmentRepository - ->loadRelation(ProductDomainObject::class) - ->findWhere([ - 'event_id' => $eventId, - 'applies_to' => CapacityAssignmentAppliesTo::PRODUCTS->name, - 'status' => CapacityAssignmentStatus::ACTIVE->name, - ]); + // Capacity assignments are deliberately ignored for recurring events + // to mirror OrderCreateRequestValidationService::validateOverallCapacity: + // a single assignment that spans every session in the series doesn't + // map to per-occurrence capacity, and applying it as a per-product min + // here while skipping the aggregate check at validation lets two + // products that share an assignment each pass independently and then + // overdraw the shared cap on completion. Until per-occurrence + // assignments exist, treat them as inapplicable for recurring events + // end-to-end. + $event = $this->eventRepository->findById($eventId); + $isRecurring = $event !== null && $event->isRecurring(); + + $capacities = collect(); + if (! $isRecurring) { + $capacities = $this->capacityAssignmentRepository + ->loadRelation(ProductDomainObject::class) + ->findWhere([ + 'event_id' => $eventId, + 'applies_to' => CapacityAssignmentAppliesTo::PRODUCTS->name, + 'status' => CapacityAssignmentStatus::ACTIVE->name, + ]); + } - $reservedProductQuantities = $this->fetchReservedProductQuantities($eventId); $productCapacities = $this->calculateProductCapacities($capacities); + $reservedProductQuantities = $this->fetchReservedProductQuantities($eventId); + $quantities = $reservedProductQuantities->map(function (AvailableProductQuantitiesDTO $dto) use ($productCapacities) { $productId = $dto->product_id; if (isset($productCapacities[$productId])) { @@ -57,21 +78,77 @@ public function getAvailableProductQuantities(int $eventId, bool $ignoreCache = return $dto; }); + if ($eventOccurrenceId !== null) { + $quantities = $this->applyOccurrenceCapacity($quantities, $eventOccurrenceId); + } + $finalData = new AvailableProductQuantitiesResponseDTO( productQuantities: $quantities, capacities: $capacities ); - if (!$ignoreCache && $this->config->get('app.homepage_product_quantities_cache_ttl')) { + if (! $ignoreCache && $eventOccurrenceId === null && $this->config->get('app.homepage_product_quantities_cache_ttl')) { $this->cache->put($this->getCacheKey($eventId), $finalData, $this->config->get('app.homepage_product_quantities_cache_ttl')); } return $finalData; } + private function applyOccurrenceCapacity(Collection $quantities, int $occurrenceId): Collection + { + $occurrence = $this->occurrenceRepository->findById($occurrenceId); + + // Cancelled, past, or missing occurrences should never report available + // capacity. Cancelled is the common case (capacity released when the + // cancel flow rolls back attendees and fires CapacityChangedEvent). + // Past covers waitlist offers / share links that resolve to a session + // whose endDate has already passed — without it, the waitlist listener + // would still offer entries against a dead date as long as raw + // capacity arithmetic checked out. Missing covers hard-deletion edge + // cases (FK normally cascades to NULL on delete, but a corrupted/ + // manually-deleted row could leave an entry pointing at a non-existent + // occurrence). + if ($occurrence === null || $occurrence->isCancelled() || $occurrence->isPast()) { + return $quantities->map(function (AvailableProductQuantitiesDTO $dto) { + $dto->quantity_available = 0; + + return $dto; + }); + } + + if ($occurrence->getCapacity() === null) { + return $quantities; + } + + $reservedForOccurrence = (int) $this->db->selectOne(<<<'SQL' + SELECT COALESCE(SUM(oi.quantity), 0) as reserved + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.event_occurrence_id = :occurrenceId + AND o.status = :reserved + AND o.reserved_until > NOW() + AND o.deleted_at IS NULL + SQL, [ + 'occurrenceId' => $occurrenceId, + 'reserved' => OrderStatus::RESERVED->name, + ])->reserved; + + $occurrenceAvailable = max(0, $occurrence->getCapacity() - $occurrence->getUsedCapacity() - $reservedForOccurrence); + + return $quantities->map(function (AvailableProductQuantitiesDTO $dto) use ($occurrenceAvailable) { + if ($dto->quantity_available !== Constants::INFINITE) { + $dto->quantity_available = min($dto->quantity_available, $occurrenceAvailable); + } else { + $dto->quantity_available = $occurrenceAvailable; + } + + return $dto; + }); + } + private function fetchReservedProductQuantities(int $eventId): Collection { - $result = $this->db->select(<<db->select(<<<'SQL' WITH reserved_quantities AS ( SELECT products.id AS product_id, @@ -128,10 +205,10 @@ private function fetchReservedProductQuantities(int $eventId): Collection GROUP BY products.id, product_prices.id, reserved_quantities.quantity_reserved; SQL, [ 'eventId' => $eventId, - 'reserved' => OrderStatus::RESERVED->name + 'reserved' => OrderStatus::RESERVED->name, ]); - return collect($result)->map(fn($row) => AvailableProductQuantitiesDTO::fromArray([ + return collect($result)->map(fn ($row) => AvailableProductQuantitiesDTO::fromArray([ 'product_id' => $row->product_id, 'price_id' => $row->product_price_id, 'product_title' => $row->product_title, @@ -139,12 +216,12 @@ private function fetchReservedProductQuantities(int $eventId): Collection 'quantity_available' => $row->unlimited_quantity_available ? Constants::INFINITE : $row->quantity_available, 'initial_quantity_available' => $row->initial_quantity_available, 'quantity_reserved' => $row->quantity_reserved, - 'capacities' => new Collection(), + 'capacities' => new Collection, ])); } /** - * @param Collection $capacities + * @param Collection $capacities */ private function calculateProductCapacities(Collection $capacities): array { @@ -152,7 +229,7 @@ private function calculateProductCapacities(Collection $capacities): array foreach ($capacities as $capacity) { foreach ($capacity->getProducts() as $product) { $productId = $product->getId(); - if (!isset($productCapacities[$productId])) { + if (! isset($productCapacities[$productId])) { $productCapacities[$productId] = collect(); } diff --git a/backend/app/Services/Domain/Product/ProductFilterService.php b/backend/app/Services/Domain/Product/ProductFilterService.php index 36af1acc19..78e3671696 100644 --- a/backend/app/Services/Domain/Product/ProductFilterService.php +++ b/backend/app/Services/Domain/Product/ProductFilterService.php @@ -3,9 +3,10 @@ namespace HiEvents\Services\Domain\Product; use HiEvents\Constants; -use HiEvents\DomainObjects\AccountConfigurationDomainObject; use HiEvents\DomainObjects\CapacityAssignmentDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; +use HiEvents\DomainObjects\OrganizerConfigurationDomainObject; +use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\ProductCategoryDomainObject; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; @@ -13,26 +14,26 @@ use HiEvents\DomainObjects\TaxAndFeesDomainObject; use HiEvents\Helper\Currency; use HiEvents\Repository\Eloquent\Value\Relationship; -use HiEvents\Repository\Interfaces\AccountRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Services\Domain\Order\OrderPlatformFeePassThroughService; +use HiEvents\Repository\Interfaces\ProductOccurrenceVisibilityRepositoryInterface; use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesDTO; use HiEvents\Services\Domain\Tax\TaxAndFeeCalculationService; use Illuminate\Support\Collection; class ProductFilterService { - private ?AccountConfigurationDomainObject $accountConfiguration = null; + private ?OrganizerConfigurationDomainObject $organizerConfiguration = null; private ?EventSettingDomainObject $eventSettings = null; private ?string $eventCurrency = null; public function __construct( - private readonly TaxAndFeeCalculationService $taxCalculationService, - private readonly ProductPriceService $productPriceService, - private readonly AvailableProductQuantitiesFetchService $fetchAvailableProductQuantitiesService, - private readonly OrderPlatformFeePassThroughService $platformFeeService, - private readonly AccountRepositoryInterface $accountRepository, - private readonly EventRepositoryInterface $eventRepository, + private readonly TaxAndFeeCalculationService $taxCalculationService, + private readonly ProductPriceService $productPriceService, + private readonly AvailableProductQuantitiesFetchService $fetchAvailableProductQuantitiesService, + private readonly OrderPlatformFeePassThroughService $platformFeeService, + private readonly EventRepositoryInterface $eventRepository, + private readonly ProductOccurrenceVisibilityRepositoryInterface $productOccurrenceVisibilityRepository, ) { } @@ -48,6 +49,7 @@ public function filter( Collection $productsCategories, ?PromoCodeDomainObject $promoCode = null, bool $hideSoldOutProducts = true, + ?int $eventOccurrenceId = null, bool $hideHiddenCategories = true, ): Collection { @@ -69,13 +71,17 @@ public function filter( $productQuantities = $this ->fetchAvailableProductQuantitiesService - ->getAvailableProductQuantities($eventId); + ->getAvailableProductQuantities($eventId, eventOccurrenceId: $eventOccurrenceId); $filteredProducts = $products - ->map(fn(ProductDomainObject $product) => $this->processProduct($product, $productQuantities->productQuantities, $promoCode)) + ->map(fn(ProductDomainObject $product) => $this->processProduct($product, $productQuantities->productQuantities, $promoCode, $eventOccurrenceId)) ->reject(fn(ProductDomainObject $product) => $this->filterProduct($product, $promoCode, $hideSoldOutProducts)) ->each(fn(ProductDomainObject $product) => $this->processProductPrices($product, $hideSoldOutProducts)); + if ($eventOccurrenceId !== null) { + $filteredProducts = $this->filterByOccurrenceVisibility($filteredProducts, $eventOccurrenceId); + } + $filteredCategories = $hideHiddenCategories ? $productsCategories->reject(fn(ProductCategoryDomainObject $category) => $category->getIsHidden()) : $productsCategories; @@ -90,21 +96,23 @@ public function filter( private function loadAccountConfiguration(int $eventId): void { - $account = $this->accountRepository - ->loadRelation(new Relationship( - domainObject: AccountConfigurationDomainObject::class, - name: 'configuration', - )) - ->findByEventId($eventId); - - $this->accountConfiguration = $account->getConfiguration(); - $event = $this->eventRepository ->loadRelation(EventSettingDomainObject::class) + ->loadRelation(new Relationship( + domainObject: OrganizerDomainObject::class, + nested: [ + new Relationship( + domainObject: OrganizerConfigurationDomainObject::class, + name: 'organizer_configuration', + ), + ], + name: 'organizer', + )) ->findById($eventId); $this->eventSettings = $event->getEventSettings(); $this->eventCurrency = $event->getCurrency(); + $this->organizerConfiguration = $event->getOrganizer()?->getOrganizerConfiguration(); } private function isHiddenByPromoCode(ProductDomainObject $product, ?PromoCodeDomainObject $promoCode): bool @@ -136,12 +144,22 @@ private function processProduct( ProductDomainObject $product, Collection $productQuantities, ?PromoCodeDomainObject $promoCode = null, + ?int $eventOccurrenceId = null, ): ProductDomainObject { if ($this->shouldProductBeDiscounted($promoCode, $product)) { - $product->getProductPrices()?->each(function (ProductPriceDomainObject $price) use ($product, $promoCode) { + $product->getProductPrices()?->each(function (ProductPriceDomainObject $price) use ($product, $promoCode, $eventOccurrenceId) { $price->setPriceBeforeDiscount($price->getPrice()); - $price->setPrice($this->productPriceService->getIndividualPrice($product, $price, $promoCode)); + $price->setPrice($this->productPriceService->getIndividualPrice($product, $price, $promoCode, $eventOccurrenceId)); + }); + } + + if ($eventOccurrenceId !== null && !$this->shouldProductBeDiscounted($promoCode, $product)) { + $product->getProductPrices()?->each(function (ProductPriceDomainObject $price) use ($product, $eventOccurrenceId) { + $overridePrice = $this->productPriceService->getIndividualPrice($product, $price, null, $eventOccurrenceId); + if ($overridePrice !== $price->getPrice()) { + $price->setPrice($overridePrice); + } }); } @@ -226,12 +244,12 @@ private function processProductPrice(ProductDomainObject $product, ProductPriceD private function calculatePlatformFee(float $total): float { - if ($this->accountConfiguration === null || $this->eventSettings === null) { + if ($this->organizerConfiguration === null || $this->eventSettings === null) { return 0.0; } return $this->platformFeeService->calculatePlatformFee( - accountConfiguration: $this->accountConfiguration, + organizerConfiguration: $this->organizerConfiguration, eventSettings: $this->eventSettings, total: $total, quantity: 1, @@ -304,6 +322,23 @@ private function processProductPrices(ProductDomainObject $product, bool $hideSo ); } + private function filterByOccurrenceVisibility(Collection $products, int $eventOccurrenceId): Collection + { + $visibilityRules = $this->productOccurrenceVisibilityRepository->findWhere([ + 'event_occurrence_id' => $eventOccurrenceId, + ]); + + if ($visibilityRules->isEmpty()) { + return $products; + } + + $visibleProductIds = $visibilityRules->map(fn($rule) => $rule->getProductId()); + + return $products->filter( + fn(ProductDomainObject $product) => $visibleProductIds->contains($product->getId()) + ); + } + private function getPriceAvailability(ProductPriceDomainObject $price, ProductDomainObject $product): bool { if ($product->isTieredType()) { diff --git a/backend/app/Services/Domain/Product/ProductPriceService.php b/backend/app/Services/Domain/Product/ProductPriceService.php index aa29d65503..0512271796 100644 --- a/backend/app/Services/Domain/Product/ProductPriceService.php +++ b/backend/app/Services/Domain/Product/ProductPriceService.php @@ -8,30 +8,39 @@ use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\Helper\Currency; +use HiEvents\Repository\Interfaces\ProductPriceOccurrenceOverrideRepositoryInterface; use HiEvents\Services\Domain\Product\DTO\OrderProductPriceDTO; use HiEvents\Services\Domain\Product\DTO\PriceDTO; class ProductPriceService { + public function __construct( + private readonly ProductPriceOccurrenceOverrideRepositoryInterface $priceOverrideRepository, + ) + { + } + public function getIndividualPrice( ProductDomainObject $product, ProductPriceDomainObject $price, - ?PromoCodeDomainObject $promoCode + ?PromoCodeDomainObject $promoCode, + ?int $eventOccurrenceId = null, ): float { return $this->getPrice($product, new OrderProductPriceDTO( quantity: 1, price_id: $price->getId(), - ), $promoCode)->price; + ), $promoCode, $eventOccurrenceId)->price; } public function getPrice( ProductDomainObject $product, OrderProductPriceDTO $productOrderDetail, - ?PromoCodeDomainObject $promoCode + ?PromoCodeDomainObject $promoCode, + ?int $eventOccurrenceId = null, ): PriceDTO { - $price = $this->determineProductPrice($product, $productOrderDetail); + $price = $this->determineProductPrice($product, $productOrderDetail, $eventOccurrenceId); if ($product->getType() === ProductPriceType::FREE->name) { return new PriceDTO(0.00); @@ -65,8 +74,19 @@ public function getPrice( ); } - private function determineProductPrice(ProductDomainObject $product, OrderProductPriceDTO $productOrderDetails): float + private function determineProductPrice(ProductDomainObject $product, OrderProductPriceDTO $productOrderDetails, ?int $eventOccurrenceId = null): float { + if ($eventOccurrenceId !== null) { + $override = $this->priceOverrideRepository->findFirstWhere([ + 'event_occurrence_id' => $eventOccurrenceId, + 'product_price_id' => $productOrderDetails->price_id, + ]); + + if ($override !== null) { + return (float) $override->getPrice(); + } + } + return match ($product->getType()) { ProductPriceType::DONATION->name => max($product->getPrice(), $productOrderDetails->price), ProductPriceType::PAID->name => $product->getPrice(), diff --git a/backend/app/Services/Domain/Product/ProductQuantityUpdateService.php b/backend/app/Services/Domain/Product/ProductQuantityUpdateService.php index 44fe678a22..cae1d79f03 100644 --- a/backend/app/Services/Domain/Product/ProductQuantityUpdateService.php +++ b/backend/app/Services/Domain/Product/ProductQuantityUpdateService.php @@ -6,13 +6,15 @@ use HiEvents\DomainObjects\Generated\CapacityAssignmentDomainObjectAbstract; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; +use HiEvents\DomainObjects\Status\EventOccurrenceStatus; +use HiEvents\Exceptions\OrderHasNoItemsException; use HiEvents\Repository\Interfaces\CapacityAssignmentRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use Illuminate\Database\DatabaseManager; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; -use InvalidArgumentException; class ProductQuantityUpdateService { @@ -21,13 +23,14 @@ public function __construct( private readonly ProductRepositoryInterface $productRepository, private readonly CapacityAssignmentRepositoryInterface $capacityAssignmentRepository, private readonly DatabaseManager $databaseManager, + private readonly EventOccurrenceRepositoryInterface $occurrenceRepository, ) { } - public function increaseQuantitySold(int $priceId, int $adjustment = 1): void + public function increaseQuantitySold(int $priceId, int $adjustment = 1, ?int $eventOccurrenceId = null): void { - $this->databaseManager->transaction(function () use ($priceId, $adjustment) { + $this->databaseManager->transaction(function () use ($priceId, $adjustment, $eventOccurrenceId) { $capacityAssignments = $this->getCapacityAssignments($priceId); $capacityAssignments->each(function (CapacityAssignmentDomainObjectAbstract $capacityAssignment) use ($adjustment) { @@ -39,12 +42,16 @@ public function increaseQuantitySold(int $priceId, int $adjustment = 1): void ], [ 'id' => $priceId, ]); + + if ($eventOccurrenceId !== null) { + $this->increaseOccurrenceUsedCapacity($eventOccurrenceId, $adjustment); + } }); } - public function decreaseQuantitySold(int $priceId, int $adjustment = 1): void + public function decreaseQuantitySold(int $priceId, int $adjustment = 1, ?int $eventOccurrenceId = null): void { - $this->databaseManager->transaction(function () use ($priceId, $adjustment) { + $this->databaseManager->transaction(function () use ($priceId, $adjustment, $eventOccurrenceId) { $capacityAssignments = $this->getCapacityAssignments($priceId); $capacityAssignments->each(function (CapacityAssignmentDomainObjectAbstract $capacityAssignment) use ($adjustment) { @@ -56,6 +63,10 @@ public function decreaseQuantitySold(int $priceId, int $adjustment = 1): void ], [ 'id' => $priceId, ]); + + if ($eventOccurrenceId !== null) { + $this->decreaseOccurrenceUsedCapacity($eventOccurrenceId, $adjustment); + } }); } @@ -66,7 +77,7 @@ public function updateQuantitiesFromOrder(OrderDomainObject $order): void { $this->databaseManager->transaction(function () use ($order) { if ($order->getOrderItems() === null) { - throw new InvalidArgumentException(__('Order has no order items')); + throw new OrderHasNoItemsException(__('Order has no order items')); } $this->updateProductQuantities($order); @@ -81,7 +92,11 @@ private function updateProductQuantities(OrderDomainObject $order): void { /** @var OrderItemDomainObject $orderItem */ foreach ($order->getOrderItems() as $orderItem) { - $this->increaseQuantitySold($orderItem->getProductPriceId(), $orderItem->getQuantity()); + $this->increaseQuantitySold( + $orderItem->getProductPriceId(), + $orderItem->getQuantity(), + $orderItem->getEventOccurrenceId(), + ); } } @@ -103,6 +118,52 @@ private function decreaseCapacityAssignmentUsedCapacity(int $capacityAssignmentI ]); } + private function increaseOccurrenceUsedCapacity(int $occurrenceId, int $adjustment): void + { + $this->occurrenceRepository->updateWhere([ + 'used_capacity' => DB::raw('used_capacity + ' . $adjustment), + ], [ + 'id' => $occurrenceId, + ]); + + $occurrence = $this->occurrenceRepository->findById($occurrenceId); + + if ( + $occurrence->getStatus() === EventOccurrenceStatus::ACTIVE->name + && $occurrence->getCapacity() !== null + && $occurrence->getUsedCapacity() >= $occurrence->getCapacity() + ) { + $this->occurrenceRepository->updateWhere([ + 'status' => EventOccurrenceStatus::SOLD_OUT->name, + ], [ + 'id' => $occurrenceId, + ]); + } + } + + private function decreaseOccurrenceUsedCapacity(int $occurrenceId, int $adjustment): void + { + $this->occurrenceRepository->updateWhere([ + 'used_capacity' => DB::raw('GREATEST(0, used_capacity - ' . $adjustment . ')'), + ], [ + 'id' => $occurrenceId, + ]); + + $occurrence = $this->occurrenceRepository->findById($occurrenceId); + + if ( + $occurrence->getStatus() === EventOccurrenceStatus::SOLD_OUT->name + && $occurrence->getCapacity() !== null + && $occurrence->getUsedCapacity() < $occurrence->getCapacity() + ) { + $this->occurrenceRepository->updateWhere([ + 'status' => EventOccurrenceStatus::ACTIVE->name, + ], [ + 'id' => $occurrenceId, + ]); + } + } + /** * @param int $priceId * @return Collection diff --git a/backend/app/Services/Domain/Report/AbstractReportService.php b/backend/app/Services/Domain/Report/AbstractReportService.php index 5ff76bfdb9..5edbb0579f 100644 --- a/backend/app/Services/Domain/Report/AbstractReportService.php +++ b/backend/app/Services/Domain/Report/AbstractReportService.php @@ -18,7 +18,7 @@ public function __construct( { } - public function generateReport(int $eventId, ?Carbon $startDate = null, ?Carbon $endDate = null): Collection + public function generateReport(int $eventId, ?Carbon $startDate = null, ?Carbon $endDate = null, ?int $occurrenceId = null): Collection { $event = $this->eventRepository->findById($eventId); $timezone = $event->getTimezone(); @@ -31,24 +31,34 @@ public function generateReport(int $eventId, ?Carbon $startDate = null, ?Carbon ? $startDate->copy()->setTimezone($timezone)->startOfDay() : $endDate->copy()->subDays(30)->startOfDay(); + $bindings = ['event_id' => $eventId]; + if ($occurrenceId !== null) { + $bindings['occurrence_id'] = $occurrenceId; + } + + $bindings = array_merge($bindings, $this->getAdditionalBindings($startDate, $endDate)); + $reportResults = $this->cache->remember( - key: $this->getCacheKey($eventId, $startDate, $endDate), + key: $this->getCacheKey($eventId, $startDate, $endDate, $occurrenceId), ttl: Carbon::now()->addSeconds(20), callback: fn() => $this->queryBuilder->select( - $this->getSqlQuery($startDate, $endDate), - [ - 'event_id' => $eventId, - ] + $this->getSqlQuery($startDate, $endDate, $occurrenceId), + $bindings, ) ); return collect($reportResults); } - abstract protected function getSqlQuery(Carbon $startDate, Carbon $endDate): string; + abstract protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?int $occurrenceId = null): string; + + protected function getAdditionalBindings(Carbon $startDate, Carbon $endDate): array + { + return []; + } - protected function getCacheKey(int $eventId, ?Carbon $startDate, ?Carbon $endDate): string + protected function getCacheKey(int $eventId, ?Carbon $startDate, ?Carbon $endDate, ?int $occurrenceId = null): string { - return static::class . "$eventId.{$startDate?->toDateString()}.{$endDate?->toDateString()}"; + return static::class . "$eventId.{$startDate?->toDateString()}.{$endDate?->toDateString()}.{$occurrenceId}"; } } diff --git a/backend/app/Services/Domain/Report/Factory/ReportServiceFactory.php b/backend/app/Services/Domain/Report/Factory/ReportServiceFactory.php index a18f2dbc64..cffe095efc 100644 --- a/backend/app/Services/Domain/Report/Factory/ReportServiceFactory.php +++ b/backend/app/Services/Domain/Report/Factory/ReportServiceFactory.php @@ -5,6 +5,7 @@ use HiEvents\DomainObjects\Enums\ReportTypes; use HiEvents\Services\Domain\Report\AbstractReportService; use HiEvents\Services\Domain\Report\Reports\DailySalesReport; +use HiEvents\Services\Domain\Report\Reports\OccurrenceSummaryReport; use HiEvents\Services\Domain\Report\Reports\ProductSalesReport; use HiEvents\Services\Domain\Report\Reports\PromoCodesReport; use Illuminate\Support\Facades\App; @@ -17,6 +18,7 @@ public function create(ReportTypes $reportType): AbstractReportService ReportTypes::PRODUCT_SALES => App::make(ProductSalesReport::class), ReportTypes::DAILY_SALES_REPORT => App::make(DailySalesReport::class), ReportTypes::PROMO_CODES_REPORT => App::make(PromoCodesReport::class), + ReportTypes::OCCURRENCE_SUMMARY => App::make(OccurrenceSummaryReport::class), }; } } diff --git a/backend/app/Services/Domain/Report/OrganizerReports/CheckInSummaryReport.php b/backend/app/Services/Domain/Report/OrganizerReports/CheckInSummaryReport.php index 79331ebc3d..bf3ac38f2b 100644 --- a/backend/app/Services/Domain/Report/OrganizerReports/CheckInSummaryReport.php +++ b/backend/app/Services/Domain/Report/OrganizerReports/CheckInSummaryReport.php @@ -18,11 +18,20 @@ protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?string $curr FROM events WHERE organizer_id = :organizer_id AND deleted_at IS NULL + ), + event_dates AS ( + SELECT + eo.event_id, + MIN(eo.start_date) AS start_date + FROM event_occurrences eo + WHERE eo.event_id IN (SELECT id FROM organizer_events) + AND eo.deleted_at IS NULL + GROUP BY eo.event_id ) SELECT e.id AS event_id, e.title AS event_name, - e.start_date, + ed.start_date, COALESCE(attendee_counts.total_attendees, 0) AS total_attendees, COALESCE(checkin_counts.total_checked_in, 0) AS total_checked_in, CASE @@ -31,6 +40,7 @@ protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?string $curr END AS check_in_rate, COALESCE(list_counts.check_in_lists_count, 0) AS check_in_lists_count FROM events e + LEFT JOIN event_dates ed ON e.id = ed.event_id LEFT JOIN ( SELECT event_id, @@ -61,7 +71,7 @@ protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?string $curr ) list_counts ON e.id = list_counts.event_id WHERE e.organizer_id = :organizer_id AND e.deleted_at IS NULL - ORDER BY e.start_date DESC NULLS LAST + ORDER BY ed.start_date DESC NULLS LAST SQL; } } diff --git a/backend/app/Services/Domain/Report/OrganizerReports/EventsPerformanceReport.php b/backend/app/Services/Domain/Report/OrganizerReports/EventsPerformanceReport.php index 846bf07877..d801d1d385 100644 --- a/backend/app/Services/Domain/Report/OrganizerReports/EventsPerformanceReport.php +++ b/backend/app/Services/Domain/Report/OrganizerReports/EventsPerformanceReport.php @@ -21,6 +21,16 @@ protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?string $curr WHERE organizer_id = :organizer_id AND deleted_at IS NULL ), + event_dates AS ( + SELECT + eo.event_id, + MIN(eo.start_date) AS start_date, + MAX(COALESCE(eo.end_date, eo.start_date)) AS end_date + FROM event_occurrences eo + WHERE eo.event_id IN (SELECT id FROM organizer_events) + AND eo.deleted_at IS NULL + GROUP BY eo.event_id + ), order_stats AS ( SELECT o.event_id, @@ -53,12 +63,12 @@ protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?string $curr e.id AS event_id, e.title AS event_name, e.currency AS event_currency, - e.start_date, - e.end_date, + ed.start_date, + ed.end_date, e.status, CASE - WHEN e.end_date < NOW() THEN 'past' - WHEN e.start_date <= NOW() AND (e.end_date >= NOW() OR e.end_date IS NULL) THEN 'ongoing' + WHEN ed.end_date < NOW() THEN 'past' + WHEN ed.start_date <= NOW() AND (ed.end_date >= NOW() OR ed.end_date IS NULL) THEN 'ongoing' WHEN e.status = 'LIVE' THEN 'on_sale' ELSE 'upcoming' END AS event_state, @@ -72,6 +82,7 @@ protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?string $curr COALESCE(os.unique_customers, 0) AS unique_customers, COALESCE(es.total_views, 0) AS page_views FROM events e + LEFT JOIN event_dates ed ON e.id = ed.event_id LEFT JOIN order_stats os ON e.id = os.event_id LEFT JOIN product_stats ps ON e.id = ps.event_id LEFT JOIN event_statistics es ON e.id = es.event_id @@ -80,10 +91,10 @@ protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?string $curr $eventCurrencyFilter ORDER BY CASE - WHEN e.start_date IS NULL THEN 1 + WHEN ed.start_date IS NULL THEN 1 ELSE 0 END, - e.start_date DESC + ed.start_date DESC SQL; } } diff --git a/backend/app/Services/Domain/Report/Reports/DailySalesReport.php b/backend/app/Services/Domain/Report/Reports/DailySalesReport.php index ba396f3718..725c0889bc 100644 --- a/backend/app/Services/Domain/Report/Reports/DailySalesReport.php +++ b/backend/app/Services/Domain/Report/Reports/DailySalesReport.php @@ -7,11 +7,36 @@ class DailySalesReport extends AbstractReportService { - public function getSqlQuery(Carbon $startDate, Carbon $endDate): string + public function getSqlQuery(Carbon $startDate, Carbon $endDate, ?int $occurrenceId = null): string { $startDateStr = $startDate->toDateString(); $endDateStr = $endDate->toDateString(); + if ($occurrenceId !== null) { + return << $startDate->toDateTimeString(), + 'end_date' => $endDate->toDateTimeString(), + ]; + } + + protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?int $occurrenceId = null): string + { + return <<= :start_date + AND eo.start_date <= :end_date + ORDER BY eo.start_date +SQL; + } +} diff --git a/backend/app/Services/Domain/Report/Reports/ProductSalesReport.php b/backend/app/Services/Domain/Report/Reports/ProductSalesReport.php index c29feee0cb..a6bd36effc 100644 --- a/backend/app/Services/Domain/Report/Reports/ProductSalesReport.php +++ b/backend/app/Services/Domain/Report/Reports/ProductSalesReport.php @@ -8,11 +8,14 @@ class ProductSalesReport extends AbstractReportService { - protected function getSqlQuery(Carbon $startDate, Carbon $endDate): string + protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?int $occurrenceId = null): string { $startDateString = $startDate->format('Y-m-d H:i:s'); $endDateString = $endDate->format('Y-m-d H:i:s'); $completedStatus = OrderStatus::COMPLETED->name; + $occurrenceFilter = $occurrenceId !== null + ? 'AND oi.event_occurrence_id = :occurrence_id' + : ''; return <<format('Y-m-d H:i:s'); $endDateString = $endDate->format('Y-m-d H:i:s'); $reservedString = OrderStatus::RESERVED->name; $completedStatus = OrderStatus::COMPLETED->name; + $occurrenceFilter = $occurrenceId !== null + ? 'AND oi.event_occurrence_id = :occurrence_id' + : ''; $translatedStringMap = [ 'Expired' => __('Expired'), @@ -41,6 +44,7 @@ protected function getSqlQuery(Carbon $startDate, Carbon $endDate): string AND o.event_id = :event_id AND o.created_at >= '$startDateString' AND o.created_at <= '$endDateString' + $occurrenceFilter GROUP BY o.id, diff --git a/backend/app/Services/Domain/SelfService/OrderAuditLogService.php b/backend/app/Services/Domain/SelfService/OrderAuditLogService.php index 5a53c67384..4ac5693754 100644 --- a/backend/app/Services/Domain/SelfService/OrderAuditLogService.php +++ b/backend/app/Services/Domain/SelfService/OrderAuditLogService.php @@ -77,4 +77,32 @@ public function logEmailResent( 'user_agent' => $userAgent, ]); } + + /** + * Records that an organiser overrode an occurrence's capacity ceiling when + * manually creating an attendee. The override flag intentionally bypasses a + * normally-blocking check, so the audit trail records who/when/which + * occurrence — important for later support and diagnosis when stats or + * waitlists look impossible. + */ + public function logManualAttendeeCapacityOverride( + int $eventId, + int $orderId, + int $attendeeId, + int $occurrenceId, + string $ipAddress, + ?string $userAgent, + ): void { + $this->orderAuditLogRepository->create([ + 'event_id' => $eventId, + 'order_id' => $orderId, + 'attendee_id' => $attendeeId, + 'action' => OrderAuditAction::MANUAL_ATTENDEE_CAPACITY_OVERRIDE->value, + 'old_values' => null, + 'new_values' => ['event_occurrence_id' => $occurrenceId], + 'changed_fields' => 'event_occurrence_id', + 'ip_address' => $ipAddress, + 'user_agent' => $userAgent, + ]); + } } diff --git a/backend/app/Services/Domain/SelfService/SelfServiceEditAttendeeService.php b/backend/app/Services/Domain/SelfService/SelfServiceEditAttendeeService.php index 49bdf9a721..66190e635e 100644 --- a/backend/app/Services/Domain/SelfService/SelfServiceEditAttendeeService.php +++ b/backend/app/Services/Domain/SelfService/SelfServiceEditAttendeeService.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; @@ -124,6 +125,14 @@ private function sendTicketToNewEmail(int $attendeeId, EventDomainObject $event) ->loadRelation(new Relationship(OrderDomainObject::class, nested: [ new Relationship(OrderItemDomainObject::class), ], name: 'order')) + ->loadRelation(new Relationship( + domainObject: EventOccurrenceDomainObject::class, + name: 'event_occurrence', + )) + ->loadRelation(new Relationship( + domainObject: ProductDomainObject::class, + name: 'product', + )) ->findById($attendeeId); $this->sendAttendeeTicketService->send( diff --git a/backend/app/Services/Domain/SelfService/SelfServiceEditOrderService.php b/backend/app/Services/Domain/SelfService/SelfServiceEditOrderService.php index 0fa7727414..4a4634c86c 100644 --- a/backend/app/Services/Domain/SelfService/SelfServiceEditOrderService.php +++ b/backend/app/Services/Domain/SelfService/SelfServiceEditOrderService.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\InvoiceDomainObject; use HiEvents\DomainObjects\OrderDomainObject; @@ -62,7 +63,7 @@ public function editOrder( $emailChanged = true; } - if (!empty($updateData)) { + if (! empty($updateData)) { $oldEmail = $order->getEmail(); if ($emailChanged) { @@ -114,13 +115,22 @@ private function loadEventWithRelations(int $eventId): EventDomainObject return $this->eventRepository ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) ->loadRelation(new Relationship(EventSettingDomainObject::class)) + ->loadRelation(new Relationship(EventOccurrenceDomainObject::class)) ->findById($eventId); } private function sendConfirmationToNewEmail(int $orderId, EventDomainObject $event): void { $order = $this->orderRepository - ->loadRelation(OrderItemDomainObject::class) + ->loadRelation(new Relationship( + domainObject: OrderItemDomainObject::class, + nested: [ + new Relationship( + domainObject: EventOccurrenceDomainObject::class, + name: 'event_occurrence', + ), + ], + )) ->loadRelation(AttendeeDomainObject::class) ->loadRelation(InvoiceDomainObject::class) ->findById($orderId); diff --git a/backend/app/Services/Domain/SelfService/SelfServiceResendEmailService.php b/backend/app/Services/Domain/SelfService/SelfServiceResendEmailService.php index bf0d08142b..f4673c14df 100644 --- a/backend/app/Services/Domain/SelfService/SelfServiceResendEmailService.php +++ b/backend/app/Services/Domain/SelfService/SelfServiceResendEmailService.php @@ -4,12 +4,13 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\Enums\OrderAuditAction; -use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\InvoiceDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; +use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; @@ -39,6 +40,14 @@ public function resendAttendeeTicket( ->loadRelation(new Relationship(OrderDomainObject::class, nested: [ new Relationship(OrderItemDomainObject::class), ], name: 'order')) + ->loadRelation(new Relationship( + domainObject: EventOccurrenceDomainObject::class, + name: 'event_occurrence', + )) + ->loadRelation(new Relationship( + domainObject: ProductDomainObject::class, + name: 'product', + )) ->findFirstWhere([ 'id' => $attendeeId, 'order_id' => $orderId, @@ -75,7 +84,15 @@ public function resendOrderConfirmation( ?string $userAgent ): void { $order = $this->orderRepository - ->loadRelation(OrderItemDomainObject::class) + ->loadRelation(new Relationship( + domainObject: OrderItemDomainObject::class, + nested: [ + new Relationship( + domainObject: EventOccurrenceDomainObject::class, + name: 'event_occurrence', + ), + ], + )) ->loadRelation(AttendeeDomainObject::class) ->loadRelation(InvoiceDomainObject::class) ->findFirstWhere([ @@ -86,6 +103,7 @@ public function resendOrderConfirmation( $event = $this->eventRepository ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) ->loadRelation(new Relationship(EventSettingDomainObject::class)) + ->loadRelation(new Relationship(EventOccurrenceDomainObject::class)) ->findById($eventId); $this->sendOrderDetailsService->sendCustomerOrderSummary( diff --git a/backend/app/Services/Domain/Waitlist/CancelWaitlistEntryService.php b/backend/app/Services/Domain/Waitlist/CancelWaitlistEntryService.php index fa8aa6d541..f3fe853b6c 100644 --- a/backend/app/Services/Domain/Waitlist/CancelWaitlistEntryService.php +++ b/backend/app/Services/Domain/Waitlist/CancelWaitlistEntryService.php @@ -103,6 +103,7 @@ private function cancelEntry(WaitlistEntryDomainObject $entry): WaitlistEntryDom direction: CapacityChangeDirection::INCREASED, productId: $productPrice->getProductId(), productPriceId: $entry->getProductPriceId(), + eventOccurrenceId: $entry->getEventOccurrenceId(), )); } diff --git a/backend/app/Services/Domain/Waitlist/CreateWaitlistEntryService.php b/backend/app/Services/Domain/Waitlist/CreateWaitlistEntryService.php index c934377cdb..73c4671123 100644 --- a/backend/app/Services/Domain/Waitlist/CreateWaitlistEntryService.php +++ b/backend/app/Services/Domain/Waitlist/CreateWaitlistEntryService.php @@ -36,13 +36,14 @@ public function createEntry( /** @var WaitlistEntryDomainObject $entry */ $entry = $this->databaseManager->transaction(function () use ($dto) { - $this->waitlistEntryRepository->lockForProductPrice($dto->product_price_id); + $this->waitlistEntryRepository->lockForProductPrice($dto->product_price_id, $dto->event_occurrence_id); $this->validateNoDuplicate($dto); $position = $this->calculatePosition($dto); return $this->waitlistEntryRepository->create([ 'event_id' => $dto->event_id, 'product_price_id' => $dto->product_price_id, + 'event_occurrence_id' => $dto->event_occurrence_id, 'email' => EmailHelper::normalize($dto->email), 'first_name' => trim($dto->first_name), 'last_name' => $dto->last_name ? trim($dto->last_name) : null, @@ -78,6 +79,7 @@ private function validateNoDuplicate(CreateWaitlistEntryDTO $dto): void 'event_id' => $dto->event_id, ['status', 'in', [WaitlistEntryStatus::WAITING->name, WaitlistEntryStatus::OFFERED->name]], 'product_price_id' => $dto->product_price_id, + 'event_occurrence_id' => $dto->event_occurrence_id, ]; $existing = $this->waitlistEntryRepository->findFirstWhere($conditions); @@ -91,6 +93,6 @@ private function validateNoDuplicate(CreateWaitlistEntryDTO $dto): void private function calculatePosition(CreateWaitlistEntryDTO $dto): int { - return $this->waitlistEntryRepository->getMaxPosition($dto->product_price_id) + 1; + return $this->waitlistEntryRepository->getMaxPosition($dto->product_price_id, $dto->event_occurrence_id) + 1; } } diff --git a/backend/app/Services/Domain/Waitlist/ProcessWaitlistService.php b/backend/app/Services/Domain/Waitlist/ProcessWaitlistService.php index eedc88ffa6..9be1c88509 100644 --- a/backend/app/Services/Domain/Waitlist/ProcessWaitlistService.php +++ b/backend/app/Services/Domain/Waitlist/ProcessWaitlistService.php @@ -5,6 +5,7 @@ use HiEvents\Constants; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; +use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\DomainObjects\Status\WaitlistEntryStatus; @@ -14,10 +15,13 @@ use HiEvents\Exceptions\ResourceConflictException; use HiEvents\Exceptions\ResourceNotFoundException; use HiEvents\Jobs\Waitlist\SendWaitlistOfferEmailJob; +use HiEvents\Repository\Eloquent\Value\OrderAndDirection; +use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use HiEvents\Repository\Interfaces\WaitlistEntryRepositoryInterface; use HiEvents\Services\Application\Handlers\Order\DTO\ProductOrderDetailsDTO; +use HiEvents\Services\Domain\EventOccurrence\OccurrencePurchaseEligibilityService; use HiEvents\Services\Domain\Order\OrderItemProcessingService; use HiEvents\Services\Domain\Order\OrderManagementService; use HiEvents\Services\Domain\Product\AvailableProductQuantitiesFetchService; @@ -25,54 +29,47 @@ use Illuminate\Database\DatabaseManager; use Illuminate\Support\Collection; use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; class ProcessWaitlistService { private const DEFAULT_OFFER_TIMEOUT_MINUTES = 60 * 12; // 12 hours public function __construct( - private readonly WaitlistEntryRepositoryInterface $waitlistEntryRepository, - private readonly DatabaseManager $databaseManager, - private readonly OrderManagementService $orderManagementService, - private readonly OrderItemProcessingService $orderItemProcessingService, - private readonly ProductRepositoryInterface $productRepository, + private readonly WaitlistEntryRepositoryInterface $waitlistEntryRepository, + private readonly DatabaseManager $databaseManager, + private readonly OrderManagementService $orderManagementService, + private readonly OrderItemProcessingService $orderItemProcessingService, + private readonly ProductRepositoryInterface $productRepository, private readonly AvailableProductQuantitiesFetchService $availableQuantitiesService, - private readonly ProductPriceRepositoryInterface $productPriceRepository, - ) - { - } + private readonly ProductPriceRepositoryInterface $productPriceRepository, + private readonly EventOccurrenceRepositoryInterface $eventOccurrenceRepository, + private readonly OccurrencePurchaseEligibilityService $eligibilityService, + ) {} /** * @return Collection */ public function offerToNext( - int $productPriceId, - int $quantity, - EventDomainObject $event, + int $productPriceId, + int $quantity, + EventDomainObject $event, EventSettingDomainObject $eventSettings, - ): Collection - { - return $this->databaseManager->transaction(function () use ($productPriceId, $quantity, $event, $eventSettings) { - $this->databaseManager->statement('SELECT pg_advisory_xact_lock(?)', [$event->getId()]); - $this->waitlistEntryRepository->lockForProductPrice($productPriceId); - - $quantities = $this->availableQuantitiesService->getAvailableProductQuantities( - $event->getId(), - ignoreCache: true, + ?int $eventOccurrenceId = null, + ): Collection { + if ($quantity <= 0) { + throw new NoCapacityAvailableException( + __('No capacity available for the selected waitlist entries.') ); + } - $availableCount = $this->getAvailableCountForPrice($quantities, $productPriceId); - - if ($availableCount <= 0) { - throw new NoCapacityAvailableException( - __('No capacity available. Available: :available', [ - 'available' => $availableCount, - ]) - ); - } + return $this->databaseManager->transaction(function () use ($productPriceId, $quantity, $event, $eventSettings, $eventOccurrenceId) { + $this->databaseManager->statement('SELECT pg_advisory_xact_lock(?)', [$event->getId()]); + $this->waitlistEntryRepository->lockForProductPrice($productPriceId, $eventOccurrenceId); - $toOffer = min($quantity, $availableCount); - $entries = $this->waitlistEntryRepository->getNextWaitingEntries($productPriceId, $toOffer); + $entries = $eventOccurrenceId !== null + ? $this->waitlistEntryRepository->getNextWaitingEntries($productPriceId, eventOccurrenceId: $eventOccurrenceId) + : $this->waitlistEntryRepository->getNextWaitingEntries($productPriceId); if ($entries->isEmpty()) { throw new NoCapacityAvailableException( @@ -81,10 +78,61 @@ public function offerToNext( } $offeredEntries = collect(); + // Capacity is fetched once per (occurrence, price) and decremented + // in-flight as offers are issued, so the loop neither re-runs the + // multi-CTE availability query per entry nor over-offers when + // multiple waiting entries share an occurrence. + $remainingByOccurrenceAndPrice = []; foreach ($entries as $entry) { + try { + $occurrenceId = $this->resolveEventOccurrenceId($event, $entry); + } catch (ResourceConflictException|ResourceNotFoundException) { + continue; + } + + // Re-validate the occurrence + product visibility right before + // offering. The entry was validated when it was created, but + // the date can be cancelled, age past, or have its product + // visibility changed before this listener fires. Skip those + // entries silently — they should be cancelled out-of-band by + // the cancel/regen flows, but defending here means a stale + // entry never produces an offer email pointing at a dead date. + if (! $this->isOccurrenceStillEligibleForEntry($event, $occurrenceId, $entry)) { + continue; + } + + $priceId = $entry->getProductPriceId(); + + if (! isset($remainingByOccurrenceAndPrice[$occurrenceId][$priceId])) { + $quantities = $this->availableQuantitiesService->getAvailableProductQuantities( + $event->getId(), + ignoreCache: true, + eventOccurrenceId: $occurrenceId, + ); + $remainingByOccurrenceAndPrice[$occurrenceId][$priceId] = $this->getAvailableCountForPrice($quantities, $priceId); + } + + if ($remainingByOccurrenceAndPrice[$occurrenceId][$priceId] <= 0) { + continue; + } + $updatedEntry = $this->offerEntry($entry, $event, $eventSettings); $offeredEntries->push($updatedEntry); + + if ($remainingByOccurrenceAndPrice[$occurrenceId][$priceId] !== Constants::INFINITE) { + $remainingByOccurrenceAndPrice[$occurrenceId][$priceId]--; + } + + if ($offeredEntries->count() >= $quantity) { + break; + } + } + + if ($offeredEntries->isEmpty()) { + throw new NoCapacityAvailableException( + __('No capacity available for the selected waitlist entries.') + ); } return $offeredEntries; @@ -95,12 +143,11 @@ public function offerToNext( * @return Collection */ public function offerSpecificEntry( - int $entryId, - int $eventId, - EventDomainObject $event, + int $entryId, + int $eventId, + EventDomainObject $event, EventSettingDomainObject $eventSettings, - ): Collection - { + ): Collection { return $this->databaseManager->transaction(function () use ($entryId, $eventId, $event, $eventSettings) { $this->databaseManager->statement('SELECT pg_advisory_xact_lock(?)', [$event->getId()]); @@ -115,26 +162,35 @@ public function offerSpecificEntry( } $validStatuses = [WaitlistEntryStatus::WAITING->name, WaitlistEntryStatus::OFFER_EXPIRED->name]; - if (!in_array($entry->getStatus(), $validStatuses, true)) { + if (! in_array($entry->getStatus(), $validStatuses, true)) { throw new ResourceConflictException( __('This waitlist entry cannot be offered in its current status') ); } - $this->waitlistEntryRepository->lockForProductPrice($entry->getProductPriceId()); - - $quantities = $this->availableQuantitiesService->getAvailableProductQuantities( - $event->getId(), - ignoreCache: true, + $this->waitlistEntryRepository->lockForProductPrice( + $entry->getProductPriceId(), + $entry->getEventOccurrenceId(), ); - $availableCount = $this->getAvailableCountForPrice($quantities, $entry->getProductPriceId()); + try { + $occurrenceId = $this->resolveEventOccurrenceId($event, $entry); + } catch (ResourceConflictException|ResourceNotFoundException $e) { + throw new ResourceConflictException( + __('This waitlist entry is no longer linked to a valid event date.'), + previous: $e, + ); + } - if ($availableCount <= 0) { + if (! $this->isOccurrenceStillEligibleForEntry($event, $occurrenceId, $entry)) { + throw new ResourceConflictException( + __('This event date is no longer available for this product.') + ); + } + + if (! $this->hasCapacityForEntry($entry, $event)) { throw new NoCapacityAvailableException( - __('No capacity available to offer this waitlist entry. You will need to increase the available quantity for the product. Available: :available', [ - 'available' => $availableCount, - ]) + __('No capacity available to offer this waitlist entry. You will need to increase the available quantity for the product or date.') ); } @@ -144,14 +200,48 @@ public function offerSpecificEntry( }); } + /** + * Checks the occurrence is still cancellable / not past, and that the + * waitlisted product remains visible on it. Wraps the eligibility service + * with `overrideCapacity: true` because the caller has already done its + * own capacity arithmetic and we only want the lifecycle gates here. + */ + private function isOccurrenceStillEligibleForEntry( + EventDomainObject $event, + int $occurrenceId, + WaitlistEntryDomainObject $entry, + ): bool { + try { + $this->eligibilityService->assertOccurrencePurchasable( + eventId: $event->getId(), + occurrenceId: $occurrenceId, + additionalQuantity: 1, + overrideCapacity: true, + ); + + $productPrice = $this->productPriceRepository->findById($entry->getProductPriceId()); + if ($productPrice === null) { + return false; + } + + $this->eligibilityService->assertProductsVisibleOnOccurrence( + $occurrenceId, + [$productPrice->getProductId()], + ); + } catch (ValidationException) { + return false; + } + + return true; + } + private function offerEntry( WaitlistEntryDomainObject $entry, - EventDomainObject $event, - EventSettingDomainObject $eventSettings, - ): WaitlistEntryDomainObject - { + EventDomainObject $event, + EventSettingDomainObject $eventSettings, + ): WaitlistEntryDomainObject { $offerExpiresAt = $this->calculateOfferExpiry($eventSettings); - $sessionIdentifier = sha1(Str::uuid() . Str::random(40)); + $sessionIdentifier = sha1(Str::uuid().Str::random(40)); $order = $this->createReservedOrder($entry, $event, $eventSettings, $sessionIdentifier); $this->waitlistEntryRepository->updateWhere( @@ -175,11 +265,10 @@ private function offerEntry( private function createReservedOrder( WaitlistEntryDomainObject $entry, - EventDomainObject $event, - EventSettingDomainObject $eventSettings, - string $sessionIdentifier, - ): OrderDomainObject - { + EventDomainObject $event, + EventSettingDomainObject $eventSettings, + string $sessionIdentifier, + ): OrderDomainObject { $timeoutMinutes = $eventSettings->getWaitlistOfferTimeoutMinutes() ?? self::DEFAULT_OFFER_TIMEOUT_MINUTES; $order = $this->orderManagementService->createNewOrder( @@ -198,6 +287,8 @@ private function createReservedOrder( ->loadRelation(ProductPriceDomainObject::class) ->findById($productPrice->getProductId()); + $eventOccurrenceId = $this->resolveEventOccurrenceId($event, $entry); + $orderDetails = collect([ new ProductOrderDetailsDTO( product_id: $product->getId(), @@ -207,6 +298,7 @@ private function createReservedOrder( price_id: $productPrice->getId(), ), ]), + event_occurrence_id: $eventOccurrenceId, ), ]); @@ -220,6 +312,34 @@ private function createReservedOrder( return $this->orderManagementService->updateOrderTotals($order, $orderItems); } + private function resolveEventOccurrenceId(EventDomainObject $event, WaitlistEntryDomainObject $entry): ?int + { + if ($entry->getEventOccurrenceId() !== null) { + return $entry->getEventOccurrenceId(); + } + + if ($event->isRecurring()) { + throw new ResourceConflictException(__('Waitlist entry is missing an event date.')); + } + + $occurrence = $this->eventOccurrenceRepository + ->findWhere( + where: [ + EventOccurrenceDomainObjectAbstract::EVENT_ID => $event->getId(), + ], + orderAndDirections: [ + new OrderAndDirection(EventOccurrenceDomainObjectAbstract::START_DATE, 'asc'), + ], + ) + ->first(); + + if ($occurrence === null) { + throw new ResourceNotFoundException(__('No occurrence found for this event.')); + } + + return $occurrence->getId(); + } + private function getAvailableCountForPrice(object $quantities, int $priceId): int { foreach ($quantities->productQuantities as $productQuantity) { @@ -228,6 +348,7 @@ private function getAvailableCountForPrice(object $quantities, int $priceId): in if ($available === Constants::INFINITE) { return Constants::INFINITE; } + return $available; } } @@ -235,6 +356,31 @@ private function getAvailableCountForPrice(object $quantities, int $priceId): in return 0; } + private function hasCapacityForEntry(WaitlistEntryDomainObject $entry, EventDomainObject $event): bool + { + // resolveEventOccurrenceId can throw two ways for an entry we cannot + // confidently route: + // - ResourceConflictException: orphan entry on a recurring event + // (FK cascaded null after delete) + // - ResourceNotFoundException: single event has no occurrences at + // all (degenerate state, but possible during creation flows) + // Either case → treat as "no capacity" so the offer batch skips it + // instead of crashing the entire waitlist run for that price. + try { + $eventOccurrenceId = $this->resolveEventOccurrenceId($event, $entry); + } catch (ResourceConflictException|ResourceNotFoundException) { + return false; + } + + $quantities = $this->availableQuantitiesService->getAvailableProductQuantities( + $event->getId(), + ignoreCache: true, + eventOccurrenceId: $eventOccurrenceId, + ); + + return $this->getAvailableCountForPrice($quantities, $entry->getProductPriceId()) > 0; + } + private function calculateOfferExpiry(EventSettingDomainObject $eventSettings): string { $timeoutMinutes = $eventSettings->getWaitlistOfferTimeoutMinutes() ?? self::DEFAULT_OFFER_TIMEOUT_MINUTES; diff --git a/backend/app/Services/Infrastructure/DomainEvents/Enums/DomainEventType.php b/backend/app/Services/Infrastructure/DomainEvents/Enums/DomainEventType.php index 690dadd394..5cde8cad7c 100644 --- a/backend/app/Services/Infrastructure/DomainEvents/Enums/DomainEventType.php +++ b/backend/app/Services/Infrastructure/DomainEvents/Enums/DomainEventType.php @@ -28,4 +28,6 @@ enum DomainEventType: string case CHECKIN_CREATED = 'checkin.created'; case CHECKIN_DELETED = 'checkin.deleted'; + + case OCCURRENCE_CANCELLED = 'occurrence.cancelled'; } diff --git a/backend/app/Services/Infrastructure/DomainEvents/Events/OccurrenceEvent.php b/backend/app/Services/Infrastructure/DomainEvents/Events/OccurrenceEvent.php new file mode 100644 index 0000000000..48b2cadd3f --- /dev/null +++ b/backend/app/Services/Infrastructure/DomainEvents/Events/OccurrenceEvent.php @@ -0,0 +1,15 @@ + __('Message shown after checkout'), 'example' => 'Thank you for your purchase!', ], + [ + 'token' => '{{ occurrence.start_date }}', + 'description' => __('The occurrence start date'), + 'example' => 'January 15, 2024', + ], + [ + 'token' => '{{ occurrence.start_time }}', + 'description' => __('The occurrence start time'), + 'example' => '7:00 PM', + ], + [ + 'token' => '{{ occurrence.end_date }}', + 'description' => __('The occurrence end date'), + 'example' => 'January 16, 2024', + ], + [ + 'token' => '{{ occurrence.end_time }}', + 'description' => __('The occurrence end time'), + 'example' => '11:00 PM', + ], + [ + 'token' => '{{ occurrence.label }}', + 'description' => __('The occurrence title suffix'), + 'example' => 'Session A', + ], ]; $orderTokens = [ @@ -214,9 +239,23 @@ public function getAvailableTokens(EmailTemplateType $type): array ], ]; + $cancellationTokens = [ + [ + 'token' => '{{ cancellation.refund_issued }}', + 'description' => __('Whether refunds are being processed for this cancellation'), + 'example' => 'true', + ], + [ + 'token' => '{{ event.url }}', + 'description' => __('Link to the event homepage'), + 'example' => 'https://example.com/event/123/summer-fest', + ], + ]; + return match ($type) { EmailTemplateType::ORDER_CONFIRMATION => array_merge($commonTokens, $orderTokens), EmailTemplateType::ATTENDEE_TICKET => array_merge($commonTokens, $orderTokens, $attendeeTokens), + EmailTemplateType::OCCURRENCE_CANCELLATION => array_merge($commonTokens, $cancellationTokens), }; } } diff --git a/backend/app/Services/Application/Handlers/Account/Vat/DTO/ViesValidationResponseDTO.php b/backend/app/Services/Infrastructure/Vat/DTO/ViesValidationResponseDTO.php similarity index 56% rename from backend/app/Services/Application/Handlers/Account/Vat/DTO/ViesValidationResponseDTO.php rename to backend/app/Services/Infrastructure/Vat/DTO/ViesValidationResponseDTO.php index aedbb1a0d9..048c51449f 100644 --- a/backend/app/Services/Application/Handlers/Account/Vat/DTO/ViesValidationResponseDTO.php +++ b/backend/app/Services/Infrastructure/Vat/DTO/ViesValidationResponseDTO.php @@ -2,20 +2,21 @@ declare(strict_types=1); -namespace HiEvents\Services\Application\Handlers\Account\Vat\DTO; +namespace HiEvents\Services\Infrastructure\Vat\DTO; use HiEvents\DataTransferObjects\BaseDataObject; class ViesValidationResponseDTO extends BaseDataObject { public function __construct( - public readonly bool $valid, + public readonly bool $valid, public readonly ?string $businessName = null, public readonly ?string $businessAddress = null, - public readonly string $countryCode = '', - public readonly string $vatNumber = '', - public readonly bool $isTransientError = false, + public readonly string $countryCode = '', + public readonly string $vatNumber = '', + public readonly bool $isTransientError = false, public readonly ?string $errorMessage = null, - ) { + ) + { } } diff --git a/backend/app/Services/Infrastructure/Vat/ViesValidationService.php b/backend/app/Services/Infrastructure/Vat/ViesValidationService.php index cf4653538e..ec6341f6a7 100644 --- a/backend/app/Services/Infrastructure/Vat/ViesValidationService.php +++ b/backend/app/Services/Infrastructure/Vat/ViesValidationService.php @@ -5,7 +5,7 @@ namespace HiEvents\Services\Infrastructure\Vat; use Exception; -use HiEvents\Services\Application\Handlers\Account\Vat\DTO\ViesValidationResponseDTO; +use HiEvents\Services\Infrastructure\Vat\DTO\ViesValidationResponseDTO; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\Factory as HttpClient; use Psr\Log\LoggerInterface; diff --git a/backend/app/Services/Infrastructure/Webhook/WebhookDispatchService.php b/backend/app/Services/Infrastructure/Webhook/WebhookDispatchService.php index 25fe3369ed..6085a85bf8 100644 --- a/backend/app/Services/Infrastructure/Webhook/WebhookDispatchService.php +++ b/backend/app/Services/Infrastructure/Webhook/WebhookDispatchService.php @@ -3,6 +3,7 @@ namespace HiEvents\Services\Infrastructure\Webhook; use HiEvents\DomainObjects\AttendeeDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\DomainObjects\QuestionAndAnswerViewDomainObject; @@ -11,6 +12,7 @@ use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\AttendeeCheckInRepositoryInterface; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use HiEvents\Repository\Interfaces\WebhookRepositoryInterface; @@ -18,6 +20,7 @@ use HiEvents\Resources\Attendee\AttendeeResource; use HiEvents\Resources\Event\EventResource; use HiEvents\Resources\CheckInList\AttendeeCheckInResource; +use HiEvents\Resources\EventOccurrence\EventOccurrenceResource; use HiEvents\Resources\Order\OrderResource; use HiEvents\Resources\Product\ProductResource; use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; @@ -28,13 +31,14 @@ class WebhookDispatchService { public function __construct( - private readonly LoggerInterface $logger, - private readonly WebhookRepositoryInterface $webhookRepository, - private readonly OrderRepositoryInterface $orderRepository, - private readonly ProductRepositoryInterface $productRepository, - private readonly AttendeeRepositoryInterface $attendeeRepository, - private readonly AttendeeCheckInRepositoryInterface $attendeeCheckInRepository, - private readonly EventRepositoryInterface $eventRepository, + private readonly LoggerInterface $logger, + private readonly WebhookRepositoryInterface $webhookRepository, + private readonly OrderRepositoryInterface $orderRepository, + private readonly ProductRepositoryInterface $productRepository, + private readonly AttendeeRepositoryInterface $attendeeRepository, + private readonly AttendeeCheckInRepositoryInterface $attendeeCheckInRepository, + private readonly EventRepositoryInterface $eventRepository, + private readonly EventOccurrenceRepositoryInterface $eventOccurrenceRepository, ) { } @@ -57,6 +61,10 @@ public function dispatchAttendeeWebhook(DomainEventType $eventType, int $attende domainObject: QuestionAndAnswerViewDomainObject::class, name: 'question_and_answer_views', )) + ->loadRelation(new Relationship( + domainObject: EventOccurrenceDomainObject::class, + name: 'event_occurrence', + )) ->findById($attendeeId); $this->dispatchWebhook( @@ -83,6 +91,21 @@ public function dispatchCheckInWebhook(DomainEventType $eventType, int $attendee ); } + public function dispatchOccurrenceWebhook(DomainEventType $eventType, int $occurrenceId): void + { + $occurrence = $this->eventOccurrenceRepository->findById($occurrenceId); + + if ($occurrence === null) { + return; + } + + $this->dispatchWebhook( + eventType: $eventType, + payload: new EventOccurrenceResource($occurrence), + eventId: $occurrence->getEventId(), + ); + } + public function dispatchProductWebhook(DomainEventType $eventType, int $productId): void { $product = $this->productRepository @@ -101,7 +124,15 @@ public function dispatchProductWebhook(DomainEventType $eventType, int $productI public function dispatchOrderWebhook(DomainEventType $eventType, int $orderId): void { $order = $this->orderRepository - ->loadRelation(OrderItemDomainObject::class) + ->loadRelation(new Relationship( + domainObject: OrderItemDomainObject::class, + nested: [ + new Relationship( + domainObject: EventOccurrenceDomainObject::class, + name: 'event_occurrence', + ), + ], + )) ->loadRelation(new Relationship( domainObject: AttendeeDomainObject::class, nested: [ @@ -109,6 +140,10 @@ public function dispatchOrderWebhook(DomainEventType $eventType, int $orderId): domainObject: QuestionAndAnswerViewDomainObject::class, name: 'question_and_answer_views', ), + new Relationship( + domainObject: EventOccurrenceDomainObject::class, + name: 'event_occurrence', + ), ], name: 'attendees') ) diff --git a/backend/app/Validators/EventRules.php b/backend/app/Validators/EventRules.php index 55bf6eed36..ce4adfa0b0 100644 --- a/backend/app/Validators/EventRules.php +++ b/backend/app/Validators/EventRules.php @@ -3,6 +3,7 @@ namespace HiEvents\Validators; use HiEvents\DomainObjects\Enums\EventCategory; +use HiEvents\DomainObjects\Enums\EventType; use Illuminate\Validation\Rule; trait EventRules @@ -12,6 +13,7 @@ public function eventRules(): array $currencies = include __DIR__ . '/../../data/currencies.php'; return array_merge($this->minimalRules(), [ + 'type' => ['nullable', Rule::in(EventType::valuesArray())], 'timezone' => ['timezone:all'], 'organizer_id' => ['required', 'integer'], 'currency' => [Rule::in(array_values($currencies))], @@ -33,12 +35,14 @@ public function eventRules(): array public function minimalRules(): array { + $isRecurring = $this->input('type') === EventType::RECURRING->name; + return [ 'title' => ['string', 'required', 'max:150', 'min:1'], 'description' => ['string', 'min:1', 'max:50000', 'nullable'], 'start_date' => [ 'date', - 'required', + $isRecurring ? 'nullable' : 'required', Rule::when($this->input('end_date') !== null, ['before_or_equal:end_date']) ], 'end_date' => ['date', 'nullable'], diff --git a/backend/config/app.php b/backend/config/app.php index 50bcabc3e9..a7492bc502 100644 --- a/backend/config/app.php +++ b/backend/config/app.php @@ -47,8 +47,8 @@ 'reset_password' => '/auth/reset-password/%s', 'confirm_email_change' => '/manage/profile/confirm-email-change/%s', 'accept_invitation' => '/auth/accept-invitation/%s', - 'stripe_connect_return_url' => '/account/payment', - 'stripe_connect_refresh_url' => '/account/payment', + 'stripe_connect_return_url' => '/manage/organizer/%d/settings#payouts', + 'stripe_connect_refresh_url' => '/manage/organizer/%d/settings#payouts', 'event_homepage' => '/event/%d/%s', 'attendee_product' => '/product/%d/%s', 'order_summary' => '/checkout/%d/%s/summary', diff --git a/backend/database/factories/AccountFactory.php b/backend/database/factories/AccountFactory.php index 2968b43255..8fd859b9a7 100644 --- a/backend/database/factories/AccountFactory.php +++ b/backend/database/factories/AccountFactory.php @@ -12,11 +12,6 @@ */ class AccountFactory extends Factory { - /** - * Define the model's default state. - * - * @return array - */ public function definition(): array { $currencies = include base_path('data/currencies.php'); @@ -27,33 +22,10 @@ public function definition(): array 'timezone' => fake()->timezone(), 'currency_code' => fake()->randomElement(array_values($currencies)), 'short_id' => IdHelper::shortId(IdHelper::ACCOUNT_PREFIX), - 'account_configuration_id' => 1, // Default account configuration is first entry + 'account_configuration_id' => 1, ]; } - /** - * Indicate that the model's stripe account id is set. - */ - public function stripeAccount(): self - { - return $this->state(fn(array $attributes) => [ - 'stripe_account_id' => fake()->stripeConnectAccountId(), - ]); - } - - /** - * Indicate that the model's stripe account connection setup is complete. - */ - public function stripeConnectSetupComplete(bool $isComplete = true): self - { - return $this->state(fn(array $attributes) => [ - 'stripe_connect_setup_complete' => $isComplete, - ]); - } - - /** - * Indicate that the model is verified. - */ public function verified(): self { return $this->state(fn(array $attributes) => [ @@ -61,9 +33,6 @@ public function verified(): self ]); } - /** - * Indicate that the model has been manually verified. - */ public function manuallyVerified(): self { return $this->state(fn(array $attributes) => [ diff --git a/backend/database/migrations/2026_02_22_000001_add_type_and_recurrence_rule_to_events.php b/backend/database/migrations/2026_02_22_000001_add_type_and_recurrence_rule_to_events.php new file mode 100644 index 0000000000..ba3407f6d4 --- /dev/null +++ b/backend/database/migrations/2026_02_22_000001_add_type_and_recurrence_rule_to_events.php @@ -0,0 +1,25 @@ +string('type', 20)->default('SINGLE'); + $table->jsonb('recurrence_rule')->nullable(); + }); + + DB::table('events')->update(['type' => 'SINGLE']); + } + + public function down(): void + { + Schema::table('events', function (Blueprint $table) { + $table->dropColumn(['type', 'recurrence_rule']); + }); + } +}; diff --git a/backend/database/migrations/2026_02_22_000002_create_event_occurrences_table.php b/backend/database/migrations/2026_02_22_000002_create_event_occurrences_table.php new file mode 100644 index 0000000000..e6d01c804d --- /dev/null +++ b/backend/database/migrations/2026_02_22_000002_create_event_occurrences_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('short_id')->index(); + $table->foreignId('event_id')->constrained('events')->onDelete('cascade'); + $table->timestamp('start_date'); + $table->timestamp('end_date')->nullable(); + $table->string('status', 20)->default('ACTIVE'); + $table->integer('capacity')->nullable(); + $table->integer('used_capacity')->default(0); + $table->string('label', 255)->nullable(); + $table->boolean('is_overridden')->default(false); + $table->timestamps(); + $table->softDeletes(); + + $table->index('start_date'); + $table->index('status'); + $table->index(['event_id', 'start_date']); + }); + } + + public function down(): void + { + Schema::dropIfExists('event_occurrences'); + } +}; diff --git a/backend/database/migrations/2026_02_22_000003_create_product_price_occurrence_overrides_table.php b/backend/database/migrations/2026_02_22_000003_create_product_price_occurrence_overrides_table.php new file mode 100644 index 0000000000..8ae4f882a0 --- /dev/null +++ b/backend/database/migrations/2026_02_22_000003_create_product_price_occurrence_overrides_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('event_occurrence_id')->constrained('event_occurrences')->onDelete('cascade'); + $table->foreignId('product_price_id')->constrained('product_prices')->onDelete('cascade'); + $table->decimal('price', 14, 2); + $table->timestamps(); + + $table->unique( + ['event_occurrence_id', 'product_price_id'], + 'ppoo_occurrence_price_unique' + ); + $table->index('event_occurrence_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_price_occurrence_overrides'); + } +}; diff --git a/backend/database/migrations/2026_02_22_000004_create_product_occurrence_visibility_table.php b/backend/database/migrations/2026_02_22_000004_create_product_occurrence_visibility_table.php new file mode 100644 index 0000000000..6ffd4f6a71 --- /dev/null +++ b/backend/database/migrations/2026_02_22_000004_create_product_occurrence_visibility_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('event_occurrence_id')->constrained('event_occurrences')->onDelete('cascade'); + $table->foreignId('product_id')->constrained('products')->onDelete('cascade'); + $table->timestamp('created_at')->useCurrent(); + + $table->unique( + ['event_occurrence_id', 'product_id'], + 'pov_occurrence_product_unique' + ); + $table->index('event_occurrence_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_occurrence_visibility'); + } +}; diff --git a/backend/database/migrations/2026_02_22_000005_add_occurrence_id_to_order_items_attendees_checkin_lists.php b/backend/database/migrations/2026_02_22_000005_add_occurrence_id_to_order_items_attendees_checkin_lists.php new file mode 100644 index 0000000000..2ac9ca4c02 --- /dev/null +++ b/backend/database/migrations/2026_02_22_000005_add_occurrence_id_to_order_items_attendees_checkin_lists.php @@ -0,0 +1,47 @@ +foreignId('event_occurrence_id') + ->nullable() + ->constrained('event_occurrences'); + $table->index('event_occurrence_id'); + }); + + Schema::table('attendees', function (Blueprint $table) { + $table->foreignId('event_occurrence_id') + ->nullable() + ->constrained('event_occurrences'); + $table->index('event_occurrence_id'); + }); + + Schema::table('check_in_lists', function (Blueprint $table) { + $table->foreignId('event_occurrence_id') + ->nullable() + ->constrained('event_occurrences') + ->nullOnDelete(); + $table->index('event_occurrence_id'); + }); + } + + public function down(): void + { + Schema::table('order_items', function (Blueprint $table) { + $table->dropConstrainedForeignId('event_occurrence_id'); + }); + + Schema::table('attendees', function (Blueprint $table) { + $table->dropConstrainedForeignId('event_occurrence_id'); + }); + + Schema::table('check_in_lists', function (Blueprint $table) { + $table->dropConstrainedForeignId('event_occurrence_id'); + }); + } +}; diff --git a/backend/database/migrations/2026_02_22_000006_backfill_occurrences_and_drop_event_dates.php b/backend/database/migrations/2026_02_22_000006_backfill_occurrences_and_drop_event_dates.php new file mode 100644 index 0000000000..145b881195 --- /dev/null +++ b/backend/database/migrations/2026_02_22_000006_backfill_occurrences_and_drop_event_dates.php @@ -0,0 +1,109 @@ +select('id', 'start_date', 'end_date', 'created_at')->orderBy('id')->chunk(500, function ($events) { + $eventIds = $events->pluck('id')->all(); + $alreadySeeded = DB::table('event_occurrences') + ->whereIn('event_id', $eventIds) + ->pluck('event_id') + ->all(); + $seededLookup = array_flip($alreadySeeded); + + foreach ($events as $event) { + if (isset($seededLookup[$event->id])) { + continue; + } + + DB::table('event_occurrences')->insert([ + 'event_id' => $event->id, + 'short_id' => \HiEvents\Helper\IdHelper::shortId(\HiEvents\Helper\IdHelper::OCCURRENCE_PREFIX), + 'start_date' => $event->start_date ?? $event->created_at ?? now(), + 'end_date' => $event->end_date, + 'status' => 'ACTIVE', + 'used_capacity' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + }); + } + + // Step 2: Backfill order_items.event_occurrence_id (idempotent — WHERE IS NULL) + DB::statement(' + UPDATE order_items oi + SET event_occurrence_id = ( + SELECT eo.id FROM event_occurrences eo + JOIN products p ON p.event_id = eo.event_id + WHERE p.id = oi.product_id + LIMIT 1 + ) + WHERE oi.event_occurrence_id IS NULL + '); + + // Step 3: Backfill attendees.event_occurrence_id (idempotent — WHERE IS NULL) + DB::statement(' + UPDATE attendees a + SET event_occurrence_id = ( + SELECT eo.id FROM event_occurrences eo + WHERE eo.event_id = a.event_id + LIMIT 1 + ) + WHERE a.event_occurrence_id IS NULL + '); + + // Step 4: Make attendees NOT NULL (no-op if already NOT NULL). + // order_items stays nullable to support future series passes. + Schema::table('attendees', function (Blueprint $table) { + $table->foreignId('event_occurrence_id')->nullable(false)->change(); + }); + + // Step 5: Drop start_date and end_date from events (no-op if already dropped). + if (Schema::hasColumn('events', 'start_date')) { + Schema::table('events', function (Blueprint $table) { + $table->dropColumn(['start_date', 'end_date']); + }); + } + }); + } + + public function down(): void + { + // Re-add date columns to events + Schema::table('events', function (Blueprint $table) { + $table->timestamp('start_date')->nullable(); + $table->timestamp('end_date')->nullable(); + }); + + // Restore dates from occurrences + DB::statement(' + UPDATE events e + SET start_date = ( + SELECT MIN(eo.start_date) FROM event_occurrences eo WHERE eo.event_id = e.id + ), + end_date = ( + SELECT MAX(eo.end_date) FROM event_occurrences eo WHERE eo.event_id = e.id + ) + '); + + // Null out occurrence FKs and make nullable again + DB::statement('UPDATE attendees SET event_occurrence_id = NULL'); + + Schema::table('attendees', function (Blueprint $table) { + $table->foreignId('event_occurrence_id')->nullable()->change(); + }); + } +}; diff --git a/backend/database/migrations/2026_03_22_000001_make_check_in_lists_occurrence_id_nullable.php b/backend/database/migrations/2026_03_22_000001_make_check_in_lists_occurrence_id_nullable.php new file mode 100644 index 0000000000..e0b26984e4 --- /dev/null +++ b/backend/database/migrations/2026_03_22_000001_make_check_in_lists_occurrence_id_nullable.php @@ -0,0 +1,34 @@ +foreignId('event_occurrence_id')->nullable()->change(); + }); + + DB::statement("UPDATE check_in_lists SET event_occurrence_id = NULL"); + } + + public function down(): void + { + DB::statement(" + UPDATE check_in_lists cl + SET event_occurrence_id = ( + SELECT eo.id FROM event_occurrences eo + WHERE eo.event_id = cl.event_id + LIMIT 1 + ) + WHERE cl.event_occurrence_id IS NULL + "); + + Schema::table('check_in_lists', function (Blueprint $table) { + $table->foreignId('event_occurrence_id')->nullable(false)->change(); + }); + } +}; diff --git a/backend/database/migrations/2026_03_24_000001_add_quantity_to_price_overrides_and_drop_soft_deletes.php b/backend/database/migrations/2026_03_24_000001_add_quantity_to_price_overrides_and_drop_soft_deletes.php new file mode 100644 index 0000000000..86fc043ae0 --- /dev/null +++ b/backend/database/migrations/2026_03_24_000001_add_quantity_to_price_overrides_and_drop_soft_deletes.php @@ -0,0 +1,34 @@ +whereNotNull('deleted_at') + ->delete(); + } + + Schema::table('product_price_occurrence_overrides', function (Blueprint $table) { + if (!Schema::hasColumn('product_price_occurrence_overrides', 'quantity_available')) { + $table->integer('quantity_available')->nullable()->after('price'); + } + if (Schema::hasColumn('product_price_occurrence_overrides', 'deleted_at')) { + $table->dropColumn('deleted_at'); + } + }); + } + + public function down(): void + { + Schema::table('product_price_occurrence_overrides', function (Blueprint $table) { + $table->dropColumn('quantity_available'); + $table->softDeletes(); + }); + } +}; diff --git a/backend/database/migrations/2026_03_26_000001_create_event_occurrence_statistics_table.php b/backend/database/migrations/2026_03_26_000001_create_event_occurrence_statistics_table.php new file mode 100644 index 0000000000..bcfbb3a045 --- /dev/null +++ b/backend/database/migrations/2026_03_26_000001_create_event_occurrence_statistics_table.php @@ -0,0 +1,84 @@ +id(); + $table->foreignId('event_id')->constrained('events'); + $table->foreignId('event_occurrence_id')->constrained('event_occurrences'); + $table->integer('products_sold')->default(0); + $table->unsignedInteger('attendees_registered')->default(0); + $table->decimal('sales_total_gross', 14, 2)->default(0); + $table->decimal('sales_total_before_additions', 14, 2)->default(0); + $table->decimal('total_tax', 14, 2)->default(0); + $table->decimal('total_fee', 14, 2)->default(0); + $table->integer('orders_created')->default(0); + $table->unsignedInteger('orders_cancelled')->default(0); + $table->decimal('total_refunded', 14, 2)->default(0); + $table->integer('version')->default(0); + $table->timestamps(); + $table->softDeletes(); + + $table->index('event_id'); + $table->unique('event_occurrence_id'); + }); + + // Backfill for single-occurrence events (1:1 mapping from event_statistics) + DB::statement(<<<'SQL' + INSERT INTO event_occurrence_statistics ( + event_id, + event_occurrence_id, + products_sold, + attendees_registered, + sales_total_gross, + sales_total_before_additions, + total_tax, + total_fee, + orders_created, + orders_cancelled, + total_refunded, + version, + created_at, + updated_at + ) + SELECT + es.event_id, + eo.id AS event_occurrence_id, + es.products_sold, + es.attendees_registered, + es.sales_total_gross, + es.sales_total_before_additions, + es.total_tax, + es.total_fee, + es.orders_created, + es.orders_cancelled, + es.total_refunded, + 0 AS version, + NOW(), + NOW() + FROM event_statistics es + INNER JOIN event_occurrences eo ON eo.event_id = es.event_id AND eo.deleted_at IS NULL + WHERE es.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM event_occurrence_statistics eos + WHERE eos.event_occurrence_id = eo.id + AND eos.deleted_at IS NULL + ) + AND ( + SELECT COUNT(*) FROM event_occurrences eo2 + WHERE eo2.event_id = es.event_id AND eo2.deleted_at IS NULL + ) = 1 + SQL); + } + + public function down(): void + { + Schema::dropIfExists('event_occurrence_statistics'); + } +}; diff --git a/backend/database/migrations/2026_03_26_000002_backfill_event_occurrence_statistics.php b/backend/database/migrations/2026_03_26_000002_backfill_event_occurrence_statistics.php new file mode 100644 index 0000000000..907f03689f --- /dev/null +++ b/backend/database/migrations/2026_03_26_000002_backfill_event_occurrence_statistics.php @@ -0,0 +1,60 @@ +id(); + $table->foreignId('event_id')->constrained('events'); + $table->foreignId('event_occurrence_id')->constrained('event_occurrences'); + $table->date('date'); + $table->integer('products_sold')->default(0); + $table->unsignedInteger('attendees_registered')->default(0); + $table->decimal('sales_total_gross', 14, 2)->default(0); + $table->decimal('sales_total_before_additions', 14, 2)->default(0); + $table->decimal('total_tax', 14, 2)->default(0); + $table->decimal('total_fee', 14, 2)->default(0); + $table->integer('orders_created')->default(0); + $table->unsignedInteger('orders_cancelled')->default(0); + $table->decimal('total_refunded', 14, 2)->default(0); + $table->integer('version')->default(0); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['event_id', 'date']); + $table->unique(['event_occurrence_id', 'date']); + }); + + // Backfill for single-occurrence events (1:1 mapping from event_daily_statistics) + DB::statement(<<<'SQL' + INSERT INTO event_occurrence_daily_statistics ( + event_id, event_occurrence_id, date, + products_sold, attendees_registered, + sales_total_gross, sales_total_before_additions, + total_tax, total_fee, + orders_created, orders_cancelled, total_refunded, + version, created_at, updated_at + ) + SELECT + eds.event_id, eo.id, eds.date, + eds.products_sold, eds.attendees_registered, + eds.sales_total_gross, eds.sales_total_before_additions, + eds.total_tax, eds.total_fee, + eds.orders_created, eds.orders_cancelled, eds.total_refunded, + 0, NOW(), NOW() + FROM event_daily_statistics eds + INNER JOIN event_occurrences eo ON eo.event_id = eds.event_id AND eo.deleted_at IS NULL + WHERE eds.deleted_at IS NULL + AND ( + SELECT COUNT(*) FROM event_occurrences eo2 + WHERE eo2.event_id = eds.event_id AND eo2.deleted_at IS NULL + ) = 1 + SQL); + } + + public function down(): void + { + Schema::dropIfExists('event_occurrence_daily_statistics'); + } +}; diff --git a/backend/database/migrations/2026_03_29_000001_add_event_occurrence_id_to_messages_table.php b/backend/database/migrations/2026_03_29_000001_add_event_occurrence_id_to_messages_table.php new file mode 100644 index 0000000000..7f0ea7b783 --- /dev/null +++ b/backend/database/migrations/2026_03_29_000001_add_event_occurrence_id_to_messages_table.php @@ -0,0 +1,36 @@ +foreignId('event_occurrence_id') + ->nullable() + ->after('order_id') + ->constrained('event_occurrences') + ->nullOnDelete(); + + $table->index('event_occurrence_id'); + }); + + DB::table('messages') + ->whereNotNull('send_data') + ->whereRaw("(send_data->>'event_occurrence_id') IS NOT NULL") + ->update([ + 'event_occurrence_id' => DB::raw("(send_data->>'event_occurrence_id')::integer"), + ]); + } + + public function down(): void + { + Schema::table('messages', function (Blueprint $table) { + $table->dropConstrainedForeignId('event_occurrence_id'); + }); + } +}; diff --git a/backend/database/migrations/2026_03_31_000001_add_event_occurrence_id_to_attendee_check_ins.php b/backend/database/migrations/2026_03_31_000001_add_event_occurrence_id_to_attendee_check_ins.php new file mode 100644 index 0000000000..f6b768a43c --- /dev/null +++ b/backend/database/migrations/2026_03_31_000001_add_event_occurrence_id_to_attendee_check_ins.php @@ -0,0 +1,27 @@ +unsignedBigInteger('event_occurrence_id')->nullable()->after('event_id'); + $table->foreign('event_occurrence_id') + ->references('id') + ->on('event_occurrences') + ->nullOnDelete(); + $table->index('event_occurrence_id'); + }); + } + + public function down(): void + { + Schema::table('attendee_check_ins', function (Blueprint $table) { + $table->dropForeign(['event_occurrence_id']); + $table->dropColumn('event_occurrence_id'); + }); + } +}; diff --git a/backend/database/migrations/2026_04_04_000001_fix_occurrence_fk_on_delete_behavior.php b/backend/database/migrations/2026_04_04_000001_fix_occurrence_fk_on_delete_behavior.php new file mode 100644 index 0000000000..9379e59a64 --- /dev/null +++ b/backend/database/migrations/2026_04_04_000001_fix_occurrence_fk_on_delete_behavior.php @@ -0,0 +1,49 @@ +dropForeign(['event_occurrence_id']); + $table->foreignId('event_occurrence_id') + ->nullable() + ->change(); + $table->foreign('event_occurrence_id') + ->references('id') + ->on('event_occurrences') + ->nullOnDelete(); + }); + + Schema::table('attendees', function (Blueprint $table) { + $table->dropForeign(['event_occurrence_id']); + $table->foreignId('event_occurrence_id') + ->nullable() + ->change(); + $table->foreign('event_occurrence_id') + ->references('id') + ->on('event_occurrences') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('order_items', function (Blueprint $table) { + $table->dropForeign(['event_occurrence_id']); + $table->foreign('event_occurrence_id') + ->references('id') + ->on('event_occurrences'); + }); + + Schema::table('attendees', function (Blueprint $table) { + $table->dropForeign(['event_occurrence_id']); + $table->foreign('event_occurrence_id') + ->references('id') + ->on('event_occurrences'); + }); + } +}; diff --git a/backend/database/migrations/2026_04_20_120000_add_public_visibility_to_check_in_lists.php b/backend/database/migrations/2026_04_20_120000_add_public_visibility_to_check_in_lists.php new file mode 100644 index 0000000000..4862967304 --- /dev/null +++ b/backend/database/migrations/2026_04_20_120000_add_public_visibility_to_check_in_lists.php @@ -0,0 +1,25 @@ +boolean('public_show_attendee_notes')->default(true); + $table->boolean('public_show_question_answers')->default(true); + $table->boolean('public_show_order_details')->default(true); + }); + } + + public function down(): void + { + Schema::table('check_in_lists', function (Blueprint $table) { + $table->dropColumn('public_show_attendee_notes'); + $table->dropColumn('public_show_question_answers'); + $table->dropColumn('public_show_order_details'); + }); + } +}; diff --git a/backend/database/migrations/2026_04_23_000001_add_is_system_default_to_check_in_lists.php b/backend/database/migrations/2026_04_23_000001_add_is_system_default_to_check_in_lists.php new file mode 100644 index 0000000000..bbd28f2984 --- /dev/null +++ b/backend/database/migrations/2026_04_23_000001_add_is_system_default_to_check_in_lists.php @@ -0,0 +1,66 @@ +boolean('is_system_default')->default(false); + }); + + // One default list per event. + DB::statement(" + CREATE UNIQUE INDEX check_in_lists_one_default_per_event + ON check_in_lists (event_id) + WHERE is_system_default = true AND deleted_at IS NULL + "); + + // Backfill one default list per existing event. Chunked for large DBs. + DB::table('events') + ->select('id') + ->whereNull('deleted_at') + ->orderBy('id') + ->chunk(500, function ($events) { + foreach ($events as $event) { + $alreadyHasDefault = DB::table('check_in_lists') + ->where('event_id', $event->id) + ->where('is_system_default', true) + ->whereNull('deleted_at') + ->exists(); + + if ($alreadyHasDefault) { + continue; + } + + DB::table('check_in_lists')->insert([ + 'event_id' => $event->id, + 'short_id' => IdHelper::shortId(IdHelper::CHECK_IN_LIST_PREFIX), + 'name' => 'Default check-in', + 'description' => null, + 'is_system_default' => true, + 'public_show_attendee_notes' => true, + 'public_show_question_answers' => true, + 'public_show_order_details' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + }); + } + + public function down(): void + { + DB::statement('DROP INDEX IF EXISTS check_in_lists_one_default_per_event'); + + Schema::table('check_in_lists', function (Blueprint $table) { + $table->dropColumn('is_system_default'); + }); + } +}; diff --git a/backend/database/migrations/2026_04_23_000002_expand_event_category_check_constraint.php b/backend/database/migrations/2026_04_23_000002_expand_event_category_check_constraint.php new file mode 100644 index 0000000000..6accfe6f86 --- /dev/null +++ b/backend/database/migrations/2026_04_23_000002_expand_event_category_check_constraint.php @@ -0,0 +1,32 @@ + "'".$v."'", $values)); + + DB::statement('ALTER TABLE events DROP CONSTRAINT IF EXISTS events_category_check'); + DB::statement("ALTER TABLE events ADD CONSTRAINT events_category_check CHECK (category IN ($quoted))"); + } + + public function down(): void + { + $originalValues = [ + 'SOCIAL', 'FOOD_DRINK', 'CHARITY', + 'MUSIC', 'ART', 'COMEDY', 'THEATER', + 'BUSINESS', 'TECH', 'EDUCATION', 'WORKSHOP', + 'SPORTS', 'FESTIVAL', 'NIGHTLIFE', + 'OTHER', + ]; + $quoted = implode(', ', array_map(fn ($v) => "'".$v."'", $originalValues)); + + DB::statement('ALTER TABLE events DROP CONSTRAINT IF EXISTS events_category_check'); + DB::statement("ALTER TABLE events ADD CONSTRAINT events_category_check CHECK (category IN ($quoted))"); + } +}; diff --git a/backend/database/migrations/2026_04_26_000001_drop_quantity_from_price_occurrence_overrides.php b/backend/database/migrations/2026_04_26_000001_drop_quantity_from_price_occurrence_overrides.php new file mode 100644 index 0000000000..80269cc563 --- /dev/null +++ b/backend/database/migrations/2026_04_26_000001_drop_quantity_from_price_occurrence_overrides.php @@ -0,0 +1,30 @@ +dropColumn('quantity_available'); + }); + } + + public function down(): void + { + if (Schema::hasColumn('product_price_occurrence_overrides', 'quantity_available')) { + return; + } + + Schema::table('product_price_occurrence_overrides', function (Blueprint $table) { + $table->integer('quantity_available')->nullable()->after('price'); + }); + } +}; diff --git a/backend/database/migrations/2026_04_28_000001_add_occurrence_id_to_waitlist_entries.php b/backend/database/migrations/2026_04_28_000001_add_occurrence_id_to_waitlist_entries.php new file mode 100644 index 0000000000..68d5d455ef --- /dev/null +++ b/backend/database/migrations/2026_04_28_000001_add_occurrence_id_to_waitlist_entries.php @@ -0,0 +1,45 @@ +foreignId('event_occurrence_id') + ->nullable() + ->after('product_price_id') + ->constrained('event_occurrences') + ->nullOnDelete(); + $table->index('event_occurrence_id'); + $table->index(['product_price_id', 'event_occurrence_id', 'status'], 'idx_waitlist_price_occ_status'); + }); + + DB::statement('DROP INDEX IF EXISTS idx_unique_email_product_price_status'); + DB::statement(" + CREATE UNIQUE INDEX idx_unique_email_product_price_occ_status + ON waitlist_entries (email, product_price_id, COALESCE(event_occurrence_id, 0), status) + WHERE status IN ('WAITING', 'OFFERED') + "); + } + + public function down(): void + { + DB::statement('DROP INDEX IF EXISTS idx_unique_email_product_price_occ_status'); + DB::statement(" + CREATE UNIQUE INDEX idx_unique_email_product_price_status + ON waitlist_entries (email, product_price_id, status) + WHERE status IN ('WAITING', 'OFFERED') + "); + + Schema::table('waitlist_entries', function (Blueprint $table) { + $table->dropIndex('idx_waitlist_price_occ_status'); + $table->dropIndex(['event_occurrence_id']); + $table->dropConstrainedForeignId('event_occurrence_id'); + }); + } +}; diff --git a/backend/database/migrations/2026_05_11_000000_create_organizer_stripe_platforms_table.php b/backend/database/migrations/2026_05_11_000000_create_organizer_stripe_platforms_table.php new file mode 100644 index 0000000000..9593b8f63a --- /dev/null +++ b/backend/database/migrations/2026_05_11_000000_create_organizer_stripe_platforms_table.php @@ -0,0 +1,64 @@ +id(); + $table->unsignedBigInteger('organizer_id'); + $table->string('stripe_connect_account_type')->nullable(); + $table->string('stripe_connect_platform', 2)->nullable(); + $table->string('stripe_account_id')->nullable(); + $table->timestamp('stripe_setup_completed_at')->nullable(); + $table->jsonb('stripe_account_details')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->foreign('organizer_id')->references('id')->on('organizers')->onDelete('cascade'); + $table->index(['organizer_id', 'stripe_connect_platform']); + $table->index('stripe_account_id'); + $table->index('stripe_connect_platform'); + }); + + // Preserve original created_at so getPrimaryStripePlatform()'s sortByDesc(created_at) + // is deterministic when an account had multiple platform rows (e.g. IE + US). + // Without this, every backfilled row shares NOW() and ties pick non-deterministically. + DB::statement(" + INSERT INTO organizer_stripe_platforms ( + organizer_id, + stripe_connect_account_type, + stripe_connect_platform, + stripe_account_id, + stripe_setup_completed_at, + stripe_account_details, + created_at, + updated_at + ) + SELECT + o.id, + asp.stripe_connect_account_type, + asp.stripe_connect_platform, + asp.stripe_account_id, + asp.stripe_setup_completed_at, + asp.stripe_account_details, + asp.created_at, + NOW() + FROM organizers o + JOIN account_stripe_platforms asp ON asp.account_id = o.account_id + WHERE asp.deleted_at IS NULL + AND o.deleted_at IS NULL + AND asp.stripe_account_id IS NOT NULL + "); + } + + public function down(): void + { + Schema::dropIfExists('organizer_stripe_platforms'); + } +}; diff --git a/backend/database/migrations/2026_05_12_000000_create_organizer_vat_settings_table.php b/backend/database/migrations/2026_05_12_000000_create_organizer_vat_settings_table.php new file mode 100644 index 0000000000..22570d68bb --- /dev/null +++ b/backend/database/migrations/2026_05_12_000000_create_organizer_vat_settings_table.php @@ -0,0 +1,70 @@ +id(); + $table->unsignedBigInteger('organizer_id'); + $table->boolean('vat_registered')->default(false); + $table->string('vat_number', 20)->nullable(); + $table->boolean('vat_validated')->default(false); + $table->string('vat_validation_status', 20)->default('PENDING'); + $table->text('vat_validation_error')->nullable(); + $table->unsignedInteger('vat_validation_attempts')->default(0); + $table->timestamp('vat_validation_date')->nullable(); + $table->string('business_name')->nullable(); + $table->string('business_address')->nullable(); + $table->string('vat_country_code', 2)->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->foreign('organizer_id') + ->references('id') + ->on('organizers') + ->onDelete('cascade'); + + $table->unique('organizer_id'); + $table->index('vat_number'); + $table->index('vat_validated'); + $table->index('vat_validation_status'); + }); + + DB::statement(" + INSERT INTO organizer_vat_settings ( + organizer_id, vat_registered, vat_number, vat_validated, vat_validation_status, + vat_validation_error, vat_validation_attempts, vat_validation_date, + business_name, business_address, vat_country_code, created_at, updated_at + ) + SELECT + o.id, + avs.vat_registered, + avs.vat_number, + avs.vat_validated, + avs.vat_validation_status, + avs.vat_validation_error, + avs.vat_validation_attempts, + avs.vat_validation_date, + avs.business_name, + avs.business_address, + avs.vat_country_code, + NOW(), + NOW() + FROM organizers o + JOIN account_vat_settings avs ON avs.account_id = o.account_id + WHERE avs.deleted_at IS NULL + AND o.deleted_at IS NULL + "); + } + + public function down(): void + { + Schema::dropIfExists('organizer_vat_settings'); + } +}; diff --git a/backend/database/migrations/2026_05_12_000001_create_organizer_configurations_table.php b/backend/database/migrations/2026_05_12_000001_create_organizer_configurations_table.php new file mode 100644 index 0000000000..73a610011d --- /dev/null +++ b/backend/database/migrations/2026_05_12_000001_create_organizer_configurations_table.php @@ -0,0 +1,93 @@ +id(); + $table->string('name'); + $table->boolean('is_system_default')->default(false); + $table->json('application_fees')->nullable(); + $table->boolean('bypass_application_fees')->default(false); + // Tracks the source row in the legacy account_configuration table so + // backfill + new-organizer assignment can match by id, not by name. + // Dropped in the follow-up that retires the legacy table. + $table->unsignedBigInteger('legacy_account_configuration_id')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index('legacy_account_configuration_id'); + }); + + // Copy every non-deleted account_configuration row 1:1 into organizer_configurations, + // preserving the legacy id pointer so we can map organizers deterministically. + DB::statement(" + INSERT INTO organizer_configurations + (name, is_system_default, application_fees, bypass_application_fees, + legacy_account_configuration_id, created_at, updated_at) + SELECT name, is_system_default, application_fees, bypass_application_fees, + id, NOW(), NOW() + FROM account_configuration + WHERE deleted_at IS NULL + "); + + // Ensure there's always a system default. If the legacy table didn't have one + // (fresh open-source install), seed from app config. + $hasDefault = DB::table('organizer_configurations')->where('is_system_default', true)->exists(); + if (!$hasDefault) { + DB::table('organizer_configurations')->insert([ + 'name' => 'Default', + 'is_system_default' => true, + 'application_fees' => json_encode([ + 'percentage' => config('app.saas_stripe_application_fee_percent'), + 'fixed' => config('app.saas_stripe_application_fee_fixed') ?? 0, + ], JSON_THROW_ON_ERROR), + 'bypass_application_fees' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + $defaultConfigId = DB::table('organizer_configurations') + ->where('is_system_default', true) + ->orderBy('id', 'asc') + ->value('id'); + + Schema::table('organizers', function (Blueprint $table) { + $table->foreignId('organizer_configuration_id') + ->nullable() + ->constrained('organizer_configurations') + ->onDelete('set null'); + }); + + // Map each organizer to the new row that mirrors its parent account's plan. + // Falls back to the system default if the account had no plan assigned. + DB::statement(" + UPDATE organizers o + SET organizer_configuration_id = COALESCE(( + SELECT oc.id + FROM organizer_configurations oc + JOIN accounts a ON a.account_configuration_id = oc.legacy_account_configuration_id + WHERE a.id = o.account_id + LIMIT 1 + ), ?) + WHERE o.deleted_at IS NULL + ", [$defaultConfigId]); + } + + public function down(): void + { + Schema::table('organizers', function (Blueprint $table) { + $table->dropForeign(['organizer_configuration_id']); + $table->dropColumn('organizer_configuration_id'); + }); + + Schema::dropIfExists('organizer_configurations'); + } +}; diff --git a/backend/phpunit.xml b/backend/phpunit.xml index 8fec6e6b02..0d665f89e7 100644 --- a/backend/phpunit.xml +++ b/backend/phpunit.xml @@ -22,7 +22,7 @@ - + diff --git a/backend/resources/views/emails/occurrence/cancellation.blade.php b/backend/resources/views/emails/occurrence/cancellation.blade.php new file mode 100644 index 0000000000..c6149200c1 --- /dev/null +++ b/backend/resources/views/emails/occurrence/cancellation.blade.php @@ -0,0 +1,32 @@ +@php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp +@php /** @var \HiEvents\DomainObjects\EventOccurrenceDomainObject $occurrence */ @endphp +@php /** @var \HiEvents\DomainObjects\OrganizerDomainObject $organizer */ @endphp +@php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp +@php /** @var string $formattedDate */ @endphp +@php /** @var string $eventUrl */ @endphp +@php /** @var bool $refundOrders */ @endphp + +@php /** @see \HiEvents\Mail\Occurrence\OccurrenceCancellationMail */ @endphp + + +# {{ $event->getTitle() }} + +{{ __('Hello') }}, + +{{ __('We\'re sorry to let you know that **:event** scheduled for **:date** has been cancelled.', ['event' => $event->getTitle(), 'date' => $formattedDate]) }} + +@if($refundOrders) +{{ __('A refund for your order will be processed automatically. Please allow a few business days for the refund to appear on your statement.') }} +@else +{{ __('If you have any questions about your order, please respond to this email.') }} +@endif + + +{{ __('View Event') }} + + +{{ __('Thank you') }},
+{{ $organizer->getName() ?: config('app.name') }} + +{!! $eventSettings->getGetEmailFooterHtml() !!} +
diff --git a/backend/resources/views/emails/orders/attendee-ticket.blade.php b/backend/resources/views/emails/orders/attendee-ticket.blade.php index 296ac24b2d..f0d469f43c 100644 --- a/backend/resources/views/emails/orders/attendee-ticket.blade.php +++ b/backend/resources/views/emails/orders/attendee-ticket.blade.php @@ -1,14 +1,38 @@ -@php use HiEvents\Helper\DateHelper; @endphp -@php /** @uses \HiEvents\Mail\Order\OrderSummary */ @endphp +@php use Carbon\Carbon; use HiEvents\Helper\DateHelper; @endphp @php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp @php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp @php /** @var \HiEvents\DomainObjects\OrganizerDomainObject $organizer */ @endphp @php /** @var \HiEvents\DomainObjects\AttendeeDomainObject $attendee */ @endphp @php /** @var \HiEvents\DomainObjects\OrderDomainObject $order */ @endphp - +@php /** @var \HiEvents\DomainObjects\EventOccurrenceDomainObject|null $occurrence */ @endphp @php /** @var string $ticketUrl */ @endphp @php /** @see \HiEvents\Mail\Attendee\AttendeeTicketMail */ @endphp +@php + $tz = $event->getTimezone(); + $displayStart = $occurrence?->getStartDate() ?? $event->getStartDate(); + $displayEnd = $occurrence?->getEndDate() ?? $event->getEndDate(); + + $formatDateTime = static fn(?string $utc) => $utc + ? (new Carbon(DateHelper::convertFromUTC($utc, $tz)))->format('D, M j, Y · g:i A') + : null; + $formatTime = static fn(?string $utc) => $utc + ? (new Carbon(DateHelper::convertFromUTC($utc, $tz)))->format('g:i A') + : null; + + $startFormatted = $formatDateTime($displayStart); + $endFormatted = null; + if ($displayStart && $displayEnd) { + // Same day → show just the end time; cross-day → show the full end timestamp. + $sameDay = substr($displayStart, 0, 10) === substr($displayEnd, 0, 10); + $endFormatted = $sameDay ? $formatTime($displayEnd) : $formatDateTime($displayEnd); + } + + $venueName = $eventSettings->getLocationDetails()['venue_name'] ?? null; + $addressString = $eventSettings->getIsOnlineEvent() ? null : $eventSettings->getAddressString(); + $productTitle = $attendee->getProduct()?->getTitle(); +@endphp + # {{ __('You\'re going to') }} {{ $event->getTitle() }}! 🎉
@@ -23,6 +47,24 @@ {{ __('Please find your ticket details below.') }} +@if($startFormatted || $venueName || $addressString || $productTitle) +
+@if($startFormatted) +{{ __('Date & Time:') }} {{ $startFormatted }}@if($endFormatted) – {{ $endFormatted }}@endif
+@if($occurrence?->getLabel()) +{{ __('Session:') }} {{ $occurrence->getLabel() }}
+@endif +@endif +@if($venueName || $addressString) +{{ __('Location:') }} {{ trim(($venueName ? $venueName . ($addressString ? ', ' : '') : '') . ($addressString ?? '')) }}
+@endif +@if($productTitle) +{{ __('Ticket:') }} {{ $productTitle }}
+@endif +{{ __('Attendee:') }} {{ trim($attendee->getFirstName() . ' ' . $attendee->getLastName()) }} +
+@endif + {{ __('View Ticket') }} diff --git a/backend/resources/views/emails/orders/summary.blade.php b/backend/resources/views/emails/orders/summary.blade.php index ab596db483..bdd61636ce 100644 --- a/backend/resources/views/emails/orders/summary.blade.php +++ b/backend/resources/views/emails/orders/summary.blade.php @@ -3,17 +3,24 @@ @php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp @php /** @var \HiEvents\DomainObjects\OrganizerDomainObject $organizer */ @endphp @php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp +@php /** @var \HiEvents\DomainObjects\EventOccurrenceDomainObject|null $occurrence */ @endphp @php /** @var string $orderUrl */ @endphp @php /** @see \HiEvents\Mail\Order\OrderSummary */ @endphp +@php + $displayStart = $occurrence?->getStartDate() ?? $event->getStartDate(); + $displayDate = (new Carbon(DateHelper::convertFromUTC($displayStart, $event->getTimezone())))->format('F j, Y'); + $displayTime = (new Carbon(DateHelper::convertFromUTC($displayStart, $event->getTimezone())))->format('g:i A'); +@endphp + # {{ __('Your Order is Confirmed! ') }} 🎉 @if($order->isOrderAwaitingOfflinePayment() === false)

-{{ __('Congratulations! Your order for :eventTitle on :eventDate at :eventTime was successful. Please find your order details below.', ['eventTitle' => $event->getTitle(), 'eventDate' => (new Carbon(DateHelper::convertFromUTC($event->getStartDate(), $event->getTimezone())))->format('F j, Y'), 'eventTime' => (new Carbon(DateHelper::convertFromUTC($event->getStartDate(), $event->getTimezone())))->format('g:i A')]) }} +{{ __('Congratulations! Your order for :eventTitle on :eventDate at :eventTime was successful. Please find your order details below.', ['eventTitle' => $event->getTitle(), 'eventDate' => $displayDate, 'eventTime' => $displayTime]) }}

@else @@ -37,7 +44,11 @@ # {{ __('Event Details') }} **{{ __('Event Name:') }}** {{ $event->getTitle() }}
-**{{ __('Date & Time:') }}** {{ __(':date at :time', ['date' => (new Carbon(DateHelper::convertFromUTC($event->getStartDate(), $event->getTimezone())))->format('F j, Y'), 'time' => (new Carbon(DateHelper::convertFromUTC($event->getStartDate(), $event->getTimezone())))->format('g:i A')]) }} +**{{ __('Date & Time:') }}** {{ __(':date at :time', ['date' => $displayDate, 'time' => $displayTime]) }} +@if($occurrence?->getLabel()) +
+**{{ __('Session:') }}** {{ $occurrence->getLabel() }} +@endif

diff --git a/backend/resources/views/emails/waitlist/confirmation.blade.php b/backend/resources/views/emails/waitlist/confirmation.blade.php index 5881800296..80f9067886 100644 --- a/backend/resources/views/emails/waitlist/confirmation.blade.php +++ b/backend/resources/views/emails/waitlist/confirmation.blade.php @@ -1,6 +1,7 @@ @php /** @var \HiEvents\DomainObjects\WaitlistEntryDomainObject $entry */ @endphp @php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp @php /** @var ?string $productName */ @endphp +@php /** @var ?string $occurrenceDateFormatted */ @endphp @php /** @var \HiEvents\DomainObjects\OrganizerDomainObject $organizer */ @endphp @php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp @php /** @var string $eventUrl */ @endphp @@ -12,7 +13,11 @@ {{ __('Hello') }}, -@if($productName) +@if($occurrenceDateFormatted && $productName) +{{ __("You have been added to the waitlist for **:product** on **:date** for the event **:event**.", ['product' => $productName, 'date' => $occurrenceDateFormatted, 'event' => $event->getTitle()]) }} +@elseif($occurrenceDateFormatted) +{{ __("You have been added to the waitlist on **:date** for the event **:event**.", ['date' => $occurrenceDateFormatted, 'event' => $event->getTitle()]) }} +@elseif($productName) {{ __("You have been added to the waitlist for **:product** for the event **:event**.", ['product' => $productName, 'event' => $event->getTitle()]) }} @else {{ __("You have been added to the waitlist for the event **:event**.", ['event' => $event->getTitle()]) }} diff --git a/backend/resources/views/emails/waitlist/offer-expired.blade.php b/backend/resources/views/emails/waitlist/offer-expired.blade.php index 2e530b286c..70df3c09ff 100644 --- a/backend/resources/views/emails/waitlist/offer-expired.blade.php +++ b/backend/resources/views/emails/waitlist/offer-expired.blade.php @@ -1,6 +1,7 @@ @php /** @var \HiEvents\DomainObjects\WaitlistEntryDomainObject $entry */ @endphp @php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp @php /** @var ?string $productName */ @endphp +@php /** @var ?string $occurrenceDateFormatted */ @endphp @php /** @var \HiEvents\DomainObjects\OrganizerDomainObject $organizer */ @endphp @php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp @php /** @var string $eventUrl */ @endphp @@ -12,7 +13,11 @@ {{ __('Hello') }}, -@if($productName) +@if($occurrenceDateFormatted && $productName) +{{ __('Unfortunately, your waitlist offer for **:product** on **:date** for the event **:event** has expired.', ['product' => $productName, 'date' => $occurrenceDateFormatted, 'event' => $event->getTitle()]) }} +@elseif($occurrenceDateFormatted) +{{ __('Unfortunately, your waitlist offer on **:date** for the event **:event** has expired.', ['date' => $occurrenceDateFormatted, 'event' => $event->getTitle()]) }} +@elseif($productName) {{ __('Unfortunately, your waitlist offer for **:product** for the event **:event** has expired.', ['product' => $productName, 'event' => $event->getTitle()]) }} @else {{ __('Unfortunately, your waitlist offer for the event **:event** has expired.', ['event' => $event->getTitle()]) }} diff --git a/backend/resources/views/emails/waitlist/offer.blade.php b/backend/resources/views/emails/waitlist/offer.blade.php index 2174f77f3a..88315ad723 100644 --- a/backend/resources/views/emails/waitlist/offer.blade.php +++ b/backend/resources/views/emails/waitlist/offer.blade.php @@ -1,6 +1,7 @@ @php /** @var \HiEvents\DomainObjects\WaitlistEntryDomainObject $entry */ @endphp @php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp @php /** @var ?string $productName */ @endphp +@php /** @var ?string $occurrenceDateFormatted */ @endphp @php /** @var \HiEvents\DomainObjects\OrganizerDomainObject $organizer */ @endphp @php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp @php /** @var string $checkoutUrl */ @endphp @@ -12,7 +13,11 @@ {{ __('Hello') }}, -@if($productName) +@if($occurrenceDateFormatted && $productName) +{{ __('Great news! A spot has become available for **:product** on **:date** for the event **:event**.', ['product' => $productName, 'date' => $occurrenceDateFormatted, 'event' => $event->getTitle()]) }} +@elseif($occurrenceDateFormatted) +{{ __('Great news! A spot has become available on **:date** for the event **:event**.', ['date' => $occurrenceDateFormatted, 'event' => $event->getTitle()]) }} +@elseif($productName) {{ __('Great news! A spot has become available for **:product** for the event **:event**.', ['product' => $productName, 'event' => $event->getTitle()]) }} @else {{ __('Great news! A spot has become available for the event **:event**.', ['event' => $event->getTitle()]) }} diff --git a/backend/routes/api.php b/backend/routes/api.php index e3947d0804..4059bc9812 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -2,11 +2,40 @@ use HiEvents\Http\Actions\Accounts\CreateAccountAction; use HiEvents\Http\Actions\Accounts\GetAccountAction; -use HiEvents\Http\Actions\Accounts\Stripe\CreateStripeConnectAccountAction; -use HiEvents\Http\Actions\Accounts\Stripe\GetStripeConnectAccountsAction; +use HiEvents\Http\Actions\Organizers\Stripe\CopyStripeConnectAccountAction; +use HiEvents\Http\Actions\Organizers\Stripe\CreateStripeConnectAccountAction; +use HiEvents\Http\Actions\Organizers\Stripe\GetStripeConnectAccountsAction; use HiEvents\Http\Actions\Accounts\UpdateAccountAction; -use HiEvents\Http\Actions\Accounts\Vat\GetAccountVatSettingAction; -use HiEvents\Http\Actions\Accounts\Vat\UpsertAccountVatSettingAction; +use HiEvents\Http\Actions\Organizers\Vat\GetOrganizerVatSettingAction; +use HiEvents\Http\Actions\Organizers\Vat\UpsertOrganizerVatSettingAction; +use HiEvents\Http\Actions\Admin\Organizers\AssignOrganizerConfigurationAction; +use HiEvents\Http\Actions\Admin\Organizers\UpdateOrganizerConfigurationAction; +use HiEvents\Http\Actions\Admin\Organizers\UpdateOrganizerVatSettingAction; +use HiEvents\Http\Actions\Admin\Accounts\GetAccountAction as GetAdminAccountAction; +use HiEvents\Http\Actions\Admin\Accounts\GetAllAccountsAction as GetAllAdminAccountsAction; +use HiEvents\Http\Actions\Admin\Accounts\UpdateAccountMessagingTierAction; +use HiEvents\Http\Actions\Admin\Attribution\GetUtmAttributionStatsAction; +use HiEvents\Http\Actions\Admin\Configurations\CreateConfigurationAction; +use HiEvents\Http\Actions\Admin\Configurations\DeleteConfigurationAction; +use HiEvents\Http\Actions\Admin\Configurations\GetAllConfigurationsAction; +use HiEvents\Http\Actions\Admin\Configurations\UpdateConfigurationAction; +use HiEvents\Http\Actions\Admin\Events\GetAllEventsAction as GetAllAdminEventsAction; +use HiEvents\Http\Actions\Admin\Events\GetUpcomingEventsAction; +use HiEvents\Http\Actions\Admin\FailedJobs\DeleteAllFailedJobsAction; +use HiEvents\Http\Actions\Admin\FailedJobs\DeleteFailedJobAction; +use HiEvents\Http\Actions\Admin\FailedJobs\GetAllFailedJobsAction; +use HiEvents\Http\Actions\Admin\FailedJobs\RetryAllFailedJobsAction; +use HiEvents\Http\Actions\Admin\FailedJobs\RetryFailedJobAction; +use HiEvents\Http\Actions\Admin\GetMessagingTiersAction; +use HiEvents\Http\Actions\Admin\GetSystemInfoAction; +use HiEvents\Http\Actions\Admin\Messages\ApproveMessageAction; +use HiEvents\Http\Actions\Admin\Messages\GetAllMessagesAction as GetAllAdminMessagesAction; +use HiEvents\Http\Actions\Admin\Orders\GetAllOrdersAction; +use HiEvents\Http\Actions\Admin\Stats\GetAdminDashboardDataAction; +use HiEvents\Http\Actions\Admin\Stats\GetAdminStatsAction; +use HiEvents\Http\Actions\Admin\Users\GetAllUsersAction; +use HiEvents\Http\Actions\Admin\Users\StartImpersonationAction; +use HiEvents\Http\Actions\Admin\Users\StopImpersonationAction; use HiEvents\Http\Actions\Affiliates\CreateAffiliateAction; use HiEvents\Http\Actions\Affiliates\DeleteAffiliateAction; use HiEvents\Http\Actions\Affiliates\ExportAffiliatesAction; @@ -41,15 +70,45 @@ use HiEvents\Http\Actions\CheckInLists\GetCheckInListsAction; use HiEvents\Http\Actions\CheckInLists\Public\CreateAttendeeCheckInPublicAction; use HiEvents\Http\Actions\CheckInLists\Public\DeleteAttendeeCheckInPublicAction; +use HiEvents\Http\Actions\CheckInLists\Public\GetCheckInListAttendeeDetailPublicAction; use HiEvents\Http\Actions\CheckInLists\Public\GetCheckInListAttendeePublicAction; use HiEvents\Http\Actions\CheckInLists\Public\GetCheckInListAttendeesPublicAction; use HiEvents\Http\Actions\CheckInLists\Public\GetCheckInListPublicAction; +use HiEvents\Http\Actions\CheckInLists\Public\GetCheckInListStatsPublicAction; use HiEvents\Http\Actions\CheckInLists\UpdateCheckInListAction; use HiEvents\Http\Actions\Common\GetColorThemesAction; use HiEvents\Http\Actions\Common\Webhooks\StripeIncomingWebhookAction; +use HiEvents\Http\Actions\EmailTemplates\CreateEventEmailTemplateAction; +use HiEvents\Http\Actions\EmailTemplates\CreateOrganizerEmailTemplateAction; +use HiEvents\Http\Actions\EmailTemplates\DeleteEventEmailTemplateAction; +use HiEvents\Http\Actions\EmailTemplates\DeleteOrganizerEmailTemplateAction; +use HiEvents\Http\Actions\EmailTemplates\GetAvailableTokensAction; +use HiEvents\Http\Actions\EmailTemplates\GetDefaultEmailTemplateAction; +use HiEvents\Http\Actions\EmailTemplates\GetEventEmailTemplatesAction; +use HiEvents\Http\Actions\EmailTemplates\GetOrganizerEmailTemplatesAction; +use HiEvents\Http\Actions\EmailTemplates\PreviewEventEmailTemplateAction; +use HiEvents\Http\Actions\EmailTemplates\PreviewOrganizerEmailTemplateAction; +use HiEvents\Http\Actions\EmailTemplates\UpdateEventEmailTemplateAction; +use HiEvents\Http\Actions\EmailTemplates\UpdateOrganizerEmailTemplateAction; +use HiEvents\Http\Actions\EventOccurrences\BulkUpdateOccurrencesAction; +use HiEvents\Http\Actions\EventOccurrences\CancelOccurrenceAction; +use HiEvents\Http\Actions\EventOccurrences\CreateEventOccurrenceAction; +use HiEvents\Http\Actions\EventOccurrences\DeleteEventOccurrenceAction; +use HiEvents\Http\Actions\EventOccurrences\DeletePriceOverrideAction; +use HiEvents\Http\Actions\EventOccurrences\GenerateOccurrencesAction; +use HiEvents\Http\Actions\EventOccurrences\GetEventOccurrenceAction; +use HiEvents\Http\Actions\EventOccurrences\GetEventOccurrencesAction; +use HiEvents\Http\Actions\EventOccurrences\GetPriceOverridesAction; +use HiEvents\Http\Actions\EventOccurrences\GetProductVisibilityAction; +use HiEvents\Http\Actions\EventOccurrences\ReactivateOccurrenceAction; +use HiEvents\Http\Actions\EventOccurrences\UpdateEventOccurrenceAction; +use HiEvents\Http\Actions\EventOccurrences\UpdateProductVisibilityAction; +use HiEvents\Http\Actions\EventOccurrences\UpsertPriceOverrideAction; use HiEvents\Http\Actions\Events\CreateEventAction; +use HiEvents\Http\Actions\Events\DeleteEventAction; use HiEvents\Http\Actions\Events\DuplicateEventAction; use HiEvents\Http\Actions\Events\GetEventAction; +use HiEvents\Http\Actions\Events\GetEventDeletionStatusAction; use HiEvents\Http\Actions\Events\GetEventPublicAction; use HiEvents\Http\Actions\Events\GetEventsAction; use HiEvents\Http\Actions\Events\GetOrganizerEventsPublicAction; @@ -58,24 +117,10 @@ use HiEvents\Http\Actions\Events\Images\GetEventImagesAction; use HiEvents\Http\Actions\Events\Stats\GetEventStatsAction; use HiEvents\Http\Actions\Events\UpdateEventAction; -use HiEvents\Http\Actions\Events\DeleteEventAction; -use HiEvents\Http\Actions\Events\GetEventDeletionStatusAction; use HiEvents\Http\Actions\Events\UpdateEventStatusAction; use HiEvents\Http\Actions\EventSettings\EditEventSettingsAction; use HiEvents\Http\Actions\EventSettings\GetEventSettingsAction; use HiEvents\Http\Actions\EventSettings\GetPlatformFeePreviewAction; -use HiEvents\Http\Actions\EmailTemplates\CreateOrganizerEmailTemplateAction; -use HiEvents\Http\Actions\EmailTemplates\CreateEventEmailTemplateAction; -use HiEvents\Http\Actions\EmailTemplates\UpdateOrganizerEmailTemplateAction; -use HiEvents\Http\Actions\EmailTemplates\UpdateEventEmailTemplateAction; -use HiEvents\Http\Actions\EmailTemplates\GetOrganizerEmailTemplatesAction; -use HiEvents\Http\Actions\EmailTemplates\GetEventEmailTemplatesAction; -use HiEvents\Http\Actions\EmailTemplates\DeleteOrganizerEmailTemplateAction; -use HiEvents\Http\Actions\EmailTemplates\DeleteEventEmailTemplateAction; -use HiEvents\Http\Actions\EmailTemplates\PreviewOrganizerEmailTemplateAction; -use HiEvents\Http\Actions\EmailTemplates\PreviewEventEmailTemplateAction; -use HiEvents\Http\Actions\EmailTemplates\GetAvailableTokensAction; -use HiEvents\Http\Actions\EmailTemplates\GetDefaultEmailTemplateAction; use HiEvents\Http\Actions\EventSettings\PartialEditEventSettingsAction; use HiEvents\Http\Actions\Images\CreateImageAction; use HiEvents\Http\Actions\Images\DeleteImageAction; @@ -102,12 +147,10 @@ use HiEvents\Http\Actions\Orders\Public\TransitionOrderToOfflinePaymentPublicAction; use HiEvents\Http\Actions\Orders\ResendOrderConfirmationAction; use HiEvents\Http\Actions\Organizers\CreateOrganizerAction; -use HiEvents\Http\Actions\SelfService\EditAttendeePublicAction; -use HiEvents\Http\Actions\SelfService\EditOrderPublicAction; -use HiEvents\Http\Actions\SelfService\ResendAttendeeTicketPublicAction; -use HiEvents\Http\Actions\SelfService\ResendOrderConfirmationPublicAction; +use HiEvents\Http\Actions\Organizers\DeleteOrganizerAction; use HiEvents\Http\Actions\Organizers\EditOrganizerAction; use HiEvents\Http\Actions\Organizers\GetOrganizerAction; +use HiEvents\Http\Actions\Organizers\GetOrganizerDeletionStatusAction; use HiEvents\Http\Actions\Organizers\GetOrganizerEventsAction; use HiEvents\Http\Actions\Organizers\GetOrganizersAction; use HiEvents\Http\Actions\Organizers\GetPublicOrganizerAction; @@ -116,8 +159,6 @@ use HiEvents\Http\Actions\Organizers\Settings\GetOrganizerSettingsAction; use HiEvents\Http\Actions\Organizers\Settings\PartialUpdateOrganizerSettingsAction; use HiEvents\Http\Actions\Organizers\Stats\GetOrganizerStatsAction; -use HiEvents\Http\Actions\Organizers\DeleteOrganizerAction; -use HiEvents\Http\Actions\Organizers\GetOrganizerDeletionStatusAction; use HiEvents\Http\Actions\Organizers\UpdateOrganizerStatusAction; use HiEvents\Http\Actions\Organizers\Webhooks\CreateOrganizerWebhookAction; use HiEvents\Http\Actions\Organizers\Webhooks\DeleteOrganizerWebhookAction; @@ -154,6 +195,10 @@ use HiEvents\Http\Actions\Reports\ExportOrganizerReportAction; use HiEvents\Http\Actions\Reports\GetOrganizerReportAction; use HiEvents\Http\Actions\Reports\GetReportAction; +use HiEvents\Http\Actions\SelfService\EditAttendeePublicAction; +use HiEvents\Http\Actions\SelfService\EditOrderPublicAction; +use HiEvents\Http\Actions\SelfService\ResendAttendeeTicketPublicAction; +use HiEvents\Http\Actions\SelfService\ResendOrderConfirmationPublicAction; use HiEvents\Http\Actions\Sitemap\GetSitemapEventsAction; use HiEvents\Http\Actions\Sitemap\GetSitemapIndexAction; use HiEvents\Http\Actions\Sitemap\GetSitemapOrganizersAction; @@ -161,6 +206,8 @@ use HiEvents\Http\Actions\TaxesAndFees\DeleteTaxOrFeeAction; use HiEvents\Http\Actions\TaxesAndFees\EditTaxOrFeeAction; use HiEvents\Http\Actions\TaxesAndFees\GetTaxOrFeeAction; +use HiEvents\Http\Actions\TicketLookup\GetOrdersByLookupTokenAction; +use HiEvents\Http\Actions\TicketLookup\SendTicketLookupEmailAction; use HiEvents\Http\Actions\Users\CancelEmailChangeAction; use HiEvents\Http\Actions\Users\ConfirmEmailAddressAction; use HiEvents\Http\Actions\Users\ConfirmEmailChangeAction; @@ -174,35 +221,6 @@ use HiEvents\Http\Actions\Users\ResendInvitationAction; use HiEvents\Http\Actions\Users\UpdateMeAction; use HiEvents\Http\Actions\Users\UpdateUserAction; -use HiEvents\Http\Actions\Admin\Accounts\AssignConfigurationAction; -use HiEvents\Http\Actions\Admin\Accounts\GetAccountAction as GetAdminAccountAction; -use HiEvents\Http\Actions\Admin\Accounts\GetAllAccountsAction as GetAllAdminAccountsAction; -use HiEvents\Http\Actions\Admin\Accounts\UpdateAccountVatSettingAction as UpdateAdminAccountVatSettingAction; -use HiEvents\Http\Actions\Admin\Configurations\CreateConfigurationAction; -use HiEvents\Http\Actions\Admin\Configurations\DeleteConfigurationAction; -use HiEvents\Http\Actions\Admin\Configurations\GetAllConfigurationsAction; -use HiEvents\Http\Actions\Admin\Configurations\UpdateConfigurationAction; -use HiEvents\Http\Actions\Admin\Events\GetAllEventsAction as GetAllAdminEventsAction; -use HiEvents\Http\Actions\Admin\Events\GetUpcomingEventsAction; -use HiEvents\Http\Actions\Admin\FailedJobs\DeleteAllFailedJobsAction; -use HiEvents\Http\Actions\Admin\FailedJobs\DeleteFailedJobAction; -use HiEvents\Http\Actions\Admin\FailedJobs\GetAllFailedJobsAction; -use HiEvents\Http\Actions\Admin\FailedJobs\RetryAllFailedJobsAction; -use HiEvents\Http\Actions\Admin\FailedJobs\RetryFailedJobAction; -use HiEvents\Http\Actions\Admin\Messages\ApproveMessageAction; -use HiEvents\Http\Actions\Admin\Messages\GetAllMessagesAction as GetAllAdminMessagesAction; -use HiEvents\Http\Actions\Admin\GetMessagingTiersAction; -use HiEvents\Http\Actions\Admin\Accounts\UpdateAccountMessagingTierAction; -use HiEvents\Http\Actions\Admin\Orders\GetAllOrdersAction; -use HiEvents\Http\Actions\Admin\Attribution\GetUtmAttributionStatsAction; -use HiEvents\Http\Actions\Admin\GetSystemInfoAction; -use HiEvents\Http\Actions\Admin\Stats\GetAdminDashboardDataAction; -use HiEvents\Http\Actions\Admin\Stats\GetAdminStatsAction; -use HiEvents\Http\Actions\Admin\Users\GetAllUsersAction; -use HiEvents\Http\Actions\Admin\Users\StartImpersonationAction; -use HiEvents\Http\Actions\Admin\Users\StopImpersonationAction; -use HiEvents\Http\Actions\TicketLookup\GetOrdersByLookupTokenAction; -use HiEvents\Http\Actions\TicketLookup\SendTicketLookupEmailAction; use HiEvents\Http\Actions\Waitlist\Organizer\CancelWaitlistEntryAction; use HiEvents\Http\Actions\Waitlist\Organizer\GetWaitlistEntriesAction; use HiEvents\Http\Actions\Waitlist\Organizer\GetWaitlistStatsAction; @@ -265,12 +283,6 @@ function (Router $router): void { // Accounts $router->get('/accounts/{account_id?}', GetAccountAction::class); $router->put('/accounts/{account_id?}', UpdateAccountAction::class); - $router->get('/accounts/{account_id}/stripe/connect_accounts', GetStripeConnectAccountsAction::class); - $router->post('/accounts/{account_id}/stripe/connect', CreateStripeConnectAccountAction::class); - - // VAT Settings - $router->get('/accounts/{account_id}/vat-settings', GetAccountVatSettingAction::class); - $router->post('/accounts/{account_id}/vat-settings', UpsertAccountVatSettingAction::class); // Organizers $router->post('/organizers', CreateOrganizerAction::class); @@ -295,6 +307,15 @@ function (Router $router): void { $router->delete('/organizers/{organizer_id}/webhooks/{webhook_id}', DeleteOrganizerWebhookAction::class); $router->get('/organizers/{organizer_id}/webhooks/{webhook_id}/logs', GetOrganizerWebhookLogsAction::class); + // Stripe Connect - Organizer level + $router->get('/organizers/{organizerId}/stripe/connect_accounts', GetStripeConnectAccountsAction::class); + $router->post('/organizers/{organizerId}/stripe/connect', CreateStripeConnectAccountAction::class); + $router->post('/organizers/{organizerId}/stripe/copy_from/{sourceOrganizerId}', CopyStripeConnectAccountAction::class); + + // VAT Settings - Organizer level + $router->get('/organizers/{organizerId}/vat-settings', GetOrganizerVatSettingAction::class); + $router->post('/organizers/{organizerId}/vat-settings', UpsertOrganizerVatSettingAction::class); + // Email Templates - Organizer level $router->get('/organizers/{organizerId}/email-templates', GetOrganizerEmailTemplatesAction::class); $router->get('/email-templates/defaults', GetDefaultEmailTemplateAction::class); @@ -441,6 +462,22 @@ function (Router $router): void { $router->post('/events/{event_id}/waitlist/offer-next', OfferWaitlistEntryAction::class); $router->delete('/events/{event_id}/waitlist/{entry_id}', CancelWaitlistEntryAction::class); + // Event Occurrences + $router->post('/events/{event_id}/occurrences/generate', GenerateOccurrencesAction::class); + $router->post('/events/{event_id}/occurrences/bulk-update', BulkUpdateOccurrencesAction::class); + $router->post('/events/{event_id}/occurrences', CreateEventOccurrenceAction::class); + $router->get('/events/{event_id}/occurrences', GetEventOccurrencesAction::class); + $router->get('/events/{event_id}/occurrences/{occurrence_id}', GetEventOccurrenceAction::class); + $router->put('/events/{event_id}/occurrences/{occurrence_id}', UpdateEventOccurrenceAction::class); + $router->delete('/events/{event_id}/occurrences/{occurrence_id}', DeleteEventOccurrenceAction::class); + $router->post('/events/{event_id}/occurrences/{occurrence_id}/cancel', CancelOccurrenceAction::class); + $router->post('/events/{event_id}/occurrences/{occurrence_id}/reactivate', ReactivateOccurrenceAction::class); + $router->put('/events/{event_id}/occurrences/{occurrence_id}/price-overrides', UpsertPriceOverrideAction::class); + $router->get('/events/{event_id}/occurrences/{occurrence_id}/price-overrides', GetPriceOverridesAction::class); + $router->delete('/events/{event_id}/occurrences/{occurrence_id}/price-overrides/{override_id}', DeletePriceOverrideAction::class); + $router->get('/events/{event_id}/occurrences/{occurrence_id}/product-visibility', GetProductVisibilityAction::class); + $router->put('/events/{event_id}/occurrences/{occurrence_id}/product-visibility', UpdateProductVisibilityAction::class); + // Images $router->post('/images', CreateImageAction::class); $router->delete('/images/{image_id}', DeleteImageAction::class); @@ -454,8 +491,9 @@ function (Router $router): void { $router->get('/attribution/stats', GetUtmAttributionStatsAction::class); $router->get('/accounts', GetAllAdminAccountsAction::class); $router->get('/accounts/{account_id}', GetAdminAccountAction::class); - $router->put('/accounts/{account_id}/vat-settings', UpdateAdminAccountVatSettingAction::class); - $router->put('/accounts/{account_id}/configuration', AssignConfigurationAction::class); + $router->put('/organizers/{organizerId}/vat-settings', UpdateOrganizerVatSettingAction::class); + $router->patch('/organizers/{organizerId}/configuration', UpdateOrganizerConfigurationAction::class); + $router->put('/organizers/{organizerId}/configuration', AssignOrganizerConfigurationAction::class); $router->get('/configurations', GetAllConfigurationsAction::class); $router->post('/configurations', CreateConfigurationAction::class); $router->put('/configurations/{configuration_id}', UpdateConfigurationAction::class); @@ -535,8 +573,10 @@ function (Router $router): void { // Check-In $router->get('/check-in-lists/{check_in_list_short_id}', GetCheckInListPublicAction::class); + $router->get('/check-in-lists/{check_in_list_short_id}/stats', GetCheckInListStatsPublicAction::class); $router->get('/check-in-lists/{check_in_list_short_id}/attendees', GetCheckInListAttendeesPublicAction::class); $router->get('/check-in-lists/{check_in_list_short_id}/attendees/{attendee_public_id}', GetCheckInListAttendeePublicAction::class); + $router->get('/check-in-lists/{check_in_list_short_id}/attendees/{attendee_public_id}/detail', GetCheckInListAttendeeDetailPublicAction::class); $router->post('/check-in-lists/{check_in_list_short_id}/check-ins', CreateAttendeeCheckInPublicAction::class); $router->delete('/check-in-lists/{check_in_list_short_id}/check-ins/{check_in_short_id}', DeleteAttendeeCheckInPublicAction::class); @@ -563,4 +603,4 @@ function (Router $router): void { } ); -include_once __DIR__ . '/mail.php'; +include_once __DIR__.'/mail.php'; diff --git a/backend/scripts/createDomainFolderStructure.sh b/backend/scripts/createDomainFolderStructure.sh deleted file mode 100755 index fbd04f9c25..0000000000 --- a/backend/scripts/createDomainFolderStructure.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/bash - -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" - -if [ $# -eq 0 ]; then - echo "No domain name provided. Usage: $0 " - exit 1 -fi - -DOMAIN_NAME=$1 - -BASE_PATH="$SCRIPT_DIR/../app/Domains/$DOMAIN_NAME" - -DIRECTORIES=( - "Services/Handlers" - "Http/Requests" - "Http/DataTransferObjects" - "Http/Middleware" - "Http/Actions" - "Repositories/Contracts" - "Repositories/Eloquent" - "Models/Eloquent" - "Mail" - "Resources" - "DomainObjects" - "Exceptions" -) - -for dir in "${DIRECTORIES[@]}"; do - mkdir -p "$BASE_PATH/$dir" -done - -echo "Folder structure for '$DOMAIN_NAME' created at $BASE_PATH" diff --git a/backend/tests/CreatesApplication.php b/backend/tests/CreatesApplication.php index cc68301129..43b976ec92 100644 --- a/backend/tests/CreatesApplication.php +++ b/backend/tests/CreatesApplication.php @@ -4,9 +4,21 @@ use Illuminate\Contracts\Console\Kernel; use Illuminate\Foundation\Application; +use Illuminate\Foundation\Testing\DatabaseMigrations; +use Illuminate\Foundation\Testing\DatabaseTransactions; +use Illuminate\Foundation\Testing\RefreshDatabase; +use RuntimeException; trait CreatesApplication { + /** + * Tracks whether application migrations have been applied to the test + * database during this PHPUnit process. Migrations are expensive (a few + * seconds) so we run them at most once per process; subsequent tests + * inherit the migrated schema. + */ + private static bool $migrationsApplied = false; + /** * Creates the application. */ @@ -16,6 +28,83 @@ public function createApplication(): Application $app->make(Kernel::class)->bootstrap(); + // The _test database guard always runs — even for pure unit tests that + // never open a connection — so a misconfigured environment can never + // touch a non-test database via an accidental query. + $this->guardAgainstNonTestDatabase($app); + + // Migrations only run when the current test class actually uses one of + // the database testing traits. Pure unit tests skip the (multi-second) + // migrate:fresh entirely, so the Unit suite stays fast and runs without + // a live Postgres connection. + if ($this->currentTestNeedsDatabase()) { + $this->ensureTestDatabaseIsMigrated($app); + } + return $app; } + + /** + * Hard safety net: any test that boots Laravel could (intentionally or not) + * issue queries against the configured database. Refuse to run unless the + * default connection's database name ends in "_test" so a misconfigured + * environment can never touch a dev/staging/prod database. + * + * Runs as part of createApplication so the check fires before any trait + * (DatabaseTransactions, RefreshDatabase, etc.) can open a connection. + */ + private function guardAgainstNonTestDatabase(Application $app): void + { + $config = $app->make('config'); + $defaultConnection = $config->get('database.default'); + $database = $config->get("database.connections.{$defaultConnection}.database"); + + if (! is_string($database) || ! str_ends_with($database, '_test')) { + throw new RuntimeException(sprintf( + 'Refusing to run %s: default database connection "%s" points at "%s", ' + .'which does not end in "_test". Set DB_DATABASE to a *_test database ' + .'(CI uses hievents_test; locally configured via backend/.env.testing).', + static::class, + (string) $defaultConnection, + (string) $database, + )); + } + } + + /** + * Apply application migrations to the test database exactly once per + * PHPUnit process. Runs inside createApplication so it executes BEFORE + * any DatabaseTransactions trait opens a wrapping transaction — some + * migrations (e.g. CREATE INDEX CONCURRENTLY) refuse to run inside a + * transaction block. + * + * Uses migrate:fresh so a leftover schema from a previous (possibly + * crashed) run is wiped clean. Per-test data isolation remains the + * responsibility of DatabaseTransactions / RefreshDatabase. + */ + private function ensureTestDatabaseIsMigrated(Application $app): void + { + if (self::$migrationsApplied) { + return; + } + + $app->make(Kernel::class)->call('migrate:fresh', ['--force' => true]); + + self::$migrationsApplied = true; + } + + /** + * Returns true when the currently running test class uses one of Laravel's + * database testing traits — the only signal we have at bootstrap time that + * the test will actually touch the database. Pure unit tests opt out by + * not using any of these traits and so skip migration entirely. + */ + private function currentTestNeedsDatabase(): bool + { + $traits = class_uses_recursive(static::class); + + return isset($traits[DatabaseTransactions::class]) + || isset($traits[RefreshDatabase::class]) + || isset($traits[DatabaseMigrations::class]); + } } diff --git a/backend/tests/Feature/Http/Actions/EmailTemplates/EmailTemplateTokenTest.php b/backend/tests/Feature/Http/Actions/EmailTemplates/EmailTemplateTokenTest.php index 030a2f06f5..4689b16594 100644 --- a/backend/tests/Feature/Http/Actions/EmailTemplates/EmailTemplateTokenTest.php +++ b/backend/tests/Feature/Http/Actions/EmailTemplates/EmailTemplateTokenTest.php @@ -68,11 +68,11 @@ public function test_can_get_order_confirmation_tokens(): void $tokens = $response->json('tokens'); $this->assertNotEmpty($tokens); - $firstNameToken = collect($tokens)->firstWhere('token', '{{ order_first_name }}'); + $firstNameToken = collect($tokens)->firstWhere('token', '{{ order.first_name }}'); $this->assertNotNull($firstNameToken); $this->assertEquals('The first name of the person who placed the order', $firstNameToken['description']); - $lastNameToken = collect($tokens)->firstWhere('token', '{{ order_last_name }}'); + $lastNameToken = collect($tokens)->firstWhere('token', '{{ order.last_name }}'); $this->assertNotNull($lastNameToken); $this->assertEquals('The last name of the person who placed the order', $lastNameToken['description']); } @@ -97,7 +97,7 @@ public function test_can_get_attendee_ticket_tokens(): void $tokens = $response->json('tokens'); $this->assertNotEmpty($tokens); - $attendeeNameToken = collect($tokens)->firstWhere('token', '{{ attendee_name }}'); + $attendeeNameToken = collect($tokens)->firstWhere('token', '{{ attendee.name }}'); $this->assertNotNull($attendeeNameToken); } @@ -128,10 +128,10 @@ public function test_tokens_include_order_specific_tokens(): void $tokens = $response->json('tokens'); $tokenNames = collect($tokens)->pluck('token')->toArray(); - $this->assertContains('{{ event_title }}', $tokenNames); - $this->assertContains('{{ order_number }}', $tokenNames); - $this->assertContains('{{ order_total }}', $tokenNames); - $this->assertContains('{{ organizer_name }}', $tokenNames); + $this->assertContains('{{ event.title }}', $tokenNames); + $this->assertContains('{{ order.number }}', $tokenNames); + $this->assertContains('{{ order.total }}', $tokenNames); + $this->assertContains('{{ organizer.name }}', $tokenNames); } public function test_tokens_have_proper_structure(): void diff --git a/backend/tests/Feature/Repository/BaseRepositoryTest.php b/backend/tests/Feature/Repository/BaseRepositoryTest.php new file mode 100644 index 0000000000..f49717d291 --- /dev/null +++ b/backend/tests/Feature/Repository/BaseRepositoryTest.php @@ -0,0 +1,725 @@ +id(); + $table->string('name'); + $table->timestamps(); + }); + + Schema::create('br_test_widgets', function (Blueprint $table) { + $table->id(); + $table->foreignId('category_id')->nullable(); + $table->string('name'); + $table->string('sku')->nullable(); + $table->integer('quantity')->default(0); + $table->decimal('price', 10, 2)->default(0); + $table->boolean('is_active')->default(true); + $table->text('description')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + $this->repository = $this->app->make(WidgetRepository::class); + $this->categoryRepository = $this->app->make(WidgetCategoryRepository::class); + } + + protected function tearDown(): void + { + Schema::dropIfExists('br_test_widgets'); + Schema::dropIfExists('br_test_widget_categories'); + + parent::tearDown(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private function makeCategory(string $name = 'Default'): WidgetCategoryModel + { + $category = new WidgetCategoryModel; + $category->name = $name; + $category->save(); + + return $category; + } + + private function makeWidget(array $overrides = []): WidgetModel + { + $widget = new WidgetModel; + $widget->fill(array_merge([ + 'name' => 'Widget '.uniqid('', true), + 'sku' => 'SKU-'.uniqid('', true), + 'quantity' => 10, + 'price' => 9.99, + 'is_active' => true, + 'category_id' => null, + ], $overrides)); + $widget->save(); + + return $widget; + } + + // ───────────────────────────────────────────────────────────────────────── + // create / insert + // ───────────────────────────────────────────────────────────────────────── + + public function test_create_inserts_a_row_and_hydrates_a_domain_object(): void + { + $widget = $this->repository->create([ + 'name' => 'Sprocket', + 'sku' => 'SP-001', + 'quantity' => 5, + 'price' => 12.50, + 'is_active' => true, + ]); + + $this->assertInstanceOf(WidgetDomainObject::class, $widget); + $this->assertNotNull($widget->getId()); + $this->assertSame('Sprocket', $widget->getName()); + $this->assertSame(5, $widget->getQuantity()); + $this->assertSame(12.50, $widget->getPrice()); + $this->assertTrue($widget->getIsActive()); + + $this->assertDatabaseHas('br_test_widgets', ['sku' => 'SP-001']); + } + + public function test_insert_bulk_inserts_rows_and_autofills_timestamps(): void + { + $result = $this->repository->insert([ + ['name' => 'A', 'sku' => 'A-1', 'quantity' => 1, 'price' => 1, 'is_active' => true], + ['name' => 'B', 'sku' => 'B-1', 'quantity' => 2, 'price' => 2, 'is_active' => true], + ]); + + $this->assertTrue($result); + $this->assertSame(2, WidgetModel::query()->count()); + // both rows should have timestamps populated by the base repository + $this->assertSame(0, WidgetModel::query()->whereNull('created_at')->count()); + $this->assertSame(0, WidgetModel::query()->whereNull('updated_at')->count()); + } + + public function test_insert_preserves_caller_supplied_timestamps(): void + { + $supplied = '2020-01-01 00:00:00'; + + $this->repository->insert([ + [ + 'name' => 'A', + 'sku' => 'A-1', + 'quantity' => 1, + 'price' => 1, + 'is_active' => true, + 'created_at' => $supplied, + 'updated_at' => $supplied, + ], + ]); + + $this->assertSame(1, WidgetModel::query()->where('created_at', $supplied)->count()); + } + + // ───────────────────────────────────────────────────────────────────────── + // findById / findFirst / findFirstByField / findFirstWhere + // ───────────────────────────────────────────────────────────────────────── + + public function test_find_by_id_returns_hydrated_domain_object(): void + { + $widget = $this->makeWidget(['name' => 'Cog']); + + $found = $this->repository->findById($widget->id); + + $this->assertInstanceOf(WidgetDomainObject::class, $found); + $this->assertSame($widget->id, $found->getId()); + $this->assertSame('Cog', $found->getName()); + } + + public function test_find_by_id_throws_when_missing(): void + { + $this->expectException(ModelNotFoundException::class); + $this->repository->findById(999_999); + } + + public function test_find_first_returns_domain_object_when_present(): void + { + $widget = $this->makeWidget(['name' => 'Hinge']); + + $found = $this->repository->findFirst($widget->id); + + $this->assertNotNull($found); + $this->assertSame('Hinge', $found->getName()); + } + + public function test_find_first_by_field_returns_match(): void + { + $this->makeWidget(['sku' => 'UNIQ-1']); + + $found = $this->repository->findFirstByField('sku', 'UNIQ-1'); + + $this->assertNotNull($found); + $this->assertSame('UNIQ-1', $found->getSku()); + } + + public function test_find_first_by_field_returns_null_when_no_match(): void + { + $found = $this->repository->findFirstByField('sku', 'does-not-exist'); + + $this->assertNull($found); + } + + public function test_find_first_where_returns_first_matching_row(): void + { + $this->makeWidget(['name' => 'A', 'is_active' => false]); + $this->makeWidget(['name' => 'B', 'is_active' => true]); + + $found = $this->repository->findFirstWhere(['is_active' => true]); + + $this->assertNotNull($found); + $this->assertSame('B', $found->getName()); + } + + public function test_find_first_where_returns_null_when_no_match(): void + { + $this->makeWidget(['is_active' => true]); + + $this->assertNull($this->repository->findFirstWhere(['is_active' => false])); + } + + // ───────────────────────────────────────────────────────────────────────── + // findWhere / findWhereIn / all / countWhere + // ───────────────────────────────────────────────────────────────────────── + + public function test_find_where_returns_collection_of_domain_objects(): void + { + $this->makeWidget(['name' => 'A', 'is_active' => true]); + $this->makeWidget(['name' => 'B', 'is_active' => true]); + $this->makeWidget(['name' => 'C', 'is_active' => false]); + + $results = $this->repository->findWhere(['is_active' => true]); + + $this->assertInstanceOf(Collection::class, $results); + $this->assertCount(2, $results); + $this->assertContainsOnlyInstancesOf(WidgetDomainObject::class, $results); + } + + public function test_find_where_orders_results_using_order_and_directions(): void + { + $this->makeWidget(['name' => 'B']); + $this->makeWidget(['name' => 'A']); + $this->makeWidget(['name' => 'C']); + + $results = $this->repository->findWhere( + where: [], + orderAndDirections: [new OrderAndDirection('name', 'asc')], + ); + + $names = $results->map(fn (WidgetDomainObject $w) => $w->getName())->all(); + $this->assertSame(['A', 'B', 'C'], $names); + } + + public function test_find_where_in_filters_by_inclusion_with_additional_where(): void + { + $w1 = $this->makeWidget(['name' => 'X', 'is_active' => true]); + $w2 = $this->makeWidget(['name' => 'Y', 'is_active' => false]); + $this->makeWidget(['name' => 'Z', 'is_active' => true]); + + $results = $this->repository->findWhereIn( + field: 'id', + values: [$w1->id, $w2->id], + additionalWhere: ['is_active' => true], + ); + + $this->assertCount(1, $results); + $this->assertSame('X', $results->first()->getName()); + } + + public function test_all_returns_every_row(): void + { + $this->makeWidget(); + $this->makeWidget(); + $this->makeWidget(); + + $this->assertCount(3, $this->repository->all()); + } + + public function test_count_where_counts_matching_rows(): void + { + $this->makeWidget(['is_active' => true]); + $this->makeWidget(['is_active' => true]); + $this->makeWidget(['is_active' => false]); + + $this->assertSame(2, $this->repository->countWhere(['is_active' => true])); + $this->assertSame(3, $this->repository->countWhere([])); + } + + // ───────────────────────────────────────────────────────────────────────── + // applyConditions DSL + // ───────────────────────────────────────────────────────────────────────── + + public function test_apply_conditions_supports_in_operator(): void + { + $a = $this->makeWidget(); + $b = $this->makeWidget(); + $this->makeWidget(); + + $results = $this->repository->findWhere([ + ['id', 'in', [$a->id, $b->id]], + ]); + + $this->assertCount(2, $results); + } + + public function test_apply_conditions_supports_not_in_operator(): void + { + $a = $this->makeWidget(); + $this->makeWidget(); + $this->makeWidget(); + + $results = $this->repository->findWhere([ + ['id', 'not in', [$a->id]], + ]); + + $this->assertCount(2, $results); + } + + public function test_apply_conditions_supports_null_operator(): void + { + $this->makeWidget(['description' => null]); + $this->makeWidget(['description' => 'has text']); + + $results = $this->repository->findWhere([ + ['description', 'null', null], + ]); + + $this->assertCount(1, $results); + } + + public function test_apply_conditions_supports_not_null_operator(): void + { + $this->makeWidget(['description' => null]); + $this->makeWidget(['description' => 'has text']); + + $results = $this->repository->findWhere([ + ['description', 'not null', null], + ]); + + $this->assertCount(1, $results); + } + + public function test_apply_conditions_supports_comparison_operators(): void + { + $this->makeWidget(['quantity' => 5]); + $this->makeWidget(['quantity' => 10]); + $this->makeWidget(['quantity' => 15]); + + $this->assertCount(2, $this->repository->findWhere([['quantity', '>=', 10]])); + $this->assertCount(1, $this->repository->findWhere([['quantity', '<', 10]])); + $this->assertCount(1, $this->repository->findWhere([['quantity', '=', 15]])); + } + + public function test_apply_conditions_treats_simple_pairs_as_equality(): void + { + $this->makeWidget(['name' => 'foo']); + $this->makeWidget(['name' => 'bar']); + + $results = $this->repository->findWhere(['name' => 'foo']); + + $this->assertCount(1, $results); + } + + public function test_apply_conditions_supports_callable_value(): void + { + $this->makeWidget(['name' => 'foo', 'is_active' => true]); + $this->makeWidget(['name' => 'bar', 'is_active' => true]); + $this->makeWidget(['name' => 'foo', 'is_active' => false]); + + $results = $this->repository->findWhere([ + 'name' => 'foo', + // closure-as-value path through applyConditions + fn ($q) => $q->where('is_active', true), + ]); + + $this->assertCount(1, $results); + } + + // ───────────────────────────────────────────────────────────────────────── + // update / delete + // ───────────────────────────────────────────────────────────────────────── + + public function test_update_from_array_persists_changes_and_returns_fresh_object(): void + { + $widget = $this->makeWidget(['name' => 'old', 'quantity' => 1]); + + $updated = $this->repository->updateFromArray($widget->id, [ + 'name' => 'new', + 'quantity' => 99, + ]); + + $this->assertSame('new', $updated->getName()); + $this->assertSame(99, $updated->getQuantity()); + $this->assertDatabaseHas('br_test_widgets', ['id' => $widget->id, 'name' => 'new']); + } + + public function test_update_where_returns_affected_count(): void + { + $this->makeWidget(['is_active' => true]); + $this->makeWidget(['is_active' => true]); + $this->makeWidget(['is_active' => false]); + + $affected = $this->repository->updateWhere( + attributes: ['name' => 'renamed'], + where: ['is_active' => true], + ); + + $this->assertSame(2, $affected); + $this->assertSame(2, WidgetModel::query()->where('name', 'renamed')->count()); + } + + public function test_update_by_id_where_updates_when_predicate_matches(): void + { + $widget = $this->makeWidget(['is_active' => true, 'name' => 'old']); + + $updated = $this->repository->updateByIdWhere( + id: $widget->id, + attributes: ['name' => 'new'], + where: ['is_active' => true], + ); + + $this->assertSame('new', $updated->getName()); + } + + public function test_update_by_id_where_throws_when_predicate_does_not_match(): void + { + $widget = $this->makeWidget(['is_active' => true]); + + $this->expectException(ModelNotFoundException::class); + $this->repository->updateByIdWhere( + id: $widget->id, + attributes: ['name' => 'new'], + where: ['is_active' => false], + ); + } + + public function test_delete_by_id_soft_deletes_the_row(): void + { + $widget = $this->makeWidget(); + + $this->assertTrue($this->repository->deleteById($widget->id)); + $this->assertSoftDeleted('br_test_widgets', ['id' => $widget->id]); + } + + public function test_delete_where_returns_affected_count(): void + { + $this->makeWidget(['is_active' => true]); + $this->makeWidget(['is_active' => true]); + $this->makeWidget(['is_active' => false]); + + $deleted = $this->repository->deleteWhere(['is_active' => true]); + + $this->assertSame(2, $deleted); + } + + // ───────────────────────────────────────────────────────────────────────── + // increment / decrement + // ───────────────────────────────────────────────────────────────────────── + + public function test_increment_bumps_an_integer_column(): void + { + $widget = $this->makeWidget(['quantity' => 10]); + + $this->repository->increment($widget->id, 'quantity', 3); + + $this->assertSame(13, (int) WidgetModel::query()->find($widget->id)->quantity); + } + + public function test_increment_supports_float_amount(): void + { + $widget = $this->makeWidget(['price' => 10.00]); + + $this->repository->increment($widget->id, 'price', 2.50); + + $this->assertSame(12.50, (float) WidgetModel::query()->find($widget->id)->price); + } + + public function test_decrement_lowers_an_integer_column(): void + { + $widget = $this->makeWidget(['quantity' => 10]); + + $this->repository->decrement($widget->id, 'quantity', 4); + + $this->assertSame(6, (int) WidgetModel::query()->find($widget->id)->quantity); + } + + public function test_increment_where_bumps_matching_rows(): void + { + $a = $this->makeWidget(['quantity' => 1, 'is_active' => true]); + $b = $this->makeWidget(['quantity' => 1, 'is_active' => true]); + $c = $this->makeWidget(['quantity' => 1, 'is_active' => false]); + + $this->repository->incrementWhere(['is_active' => true], 'quantity', 5); + + $this->assertSame(6, (int) WidgetModel::query()->find($a->id)->quantity); + $this->assertSame(6, (int) WidgetModel::query()->find($b->id)->quantity); + $this->assertSame(1, (int) WidgetModel::query()->find($c->id)->quantity); + } + + public function test_increment_each_updates_multiple_columns(): void + { + $widget = $this->makeWidget(['quantity' => 1, 'price' => 1.00]); + + $this->repository->incrementEach( + columns: ['quantity' => 2, 'price' => 3.00], + where: ['id' => $widget->id], + ); + + $fresh = WidgetModel::query()->find($widget->id); + $this->assertSame(3, (int) $fresh->quantity); + $this->assertSame(4.00, (float) $fresh->price); + } + + public function test_decrement_each_updates_multiple_columns(): void + { + $widget = $this->makeWidget(['quantity' => 10, 'price' => 10.00]); + + $this->repository->decrementEach( + where: ['id' => $widget->id], + columns: ['quantity' => 2, 'price' => 1.00], + ); + + $fresh = WidgetModel::query()->find($widget->id); + $this->assertSame(8, (int) $fresh->quantity); + $this->assertSame(9.00, (float) $fresh->price); + } + + // ───────────────────────────────────────────────────────────────────────── + // Pagination + // ───────────────────────────────────────────────────────────────────────── + + public function test_paginate_returns_a_length_aware_paginator(): void + { + for ($i = 0; $i < 5; $i++) { + $this->makeWidget(); + } + + $page = $this->repository->paginate(limit: 2); + + $this->assertInstanceOf(LengthAwarePaginator::class, $page); + $this->assertSame(5, $page->total()); + $this->assertCount(2, $page->items()); + $this->assertContainsOnlyInstancesOf(WidgetDomainObject::class, $page->items()); + } + + public function test_paginate_where_filters_then_paginates(): void + { + for ($i = 0; $i < 3; $i++) { + $this->makeWidget(['is_active' => true]); + } + $this->makeWidget(['is_active' => false]); + + $page = $this->repository->paginateWhere(['is_active' => true], limit: 2); + + $this->assertSame(3, $page->total()); + $this->assertCount(2, $page->items()); + } + + public function test_simple_paginate_where_returns_a_simple_paginator(): void + { + for ($i = 0; $i < 4; $i++) { + $this->makeWidget(['is_active' => true]); + } + + $page = $this->repository->simplePaginateWhere(['is_active' => true], limit: 2); + + $this->assertInstanceOf(Paginator::class, $page); + $this->assertCount(2, $page->items()); + } + + // ───────────────────────────────────────────────────────────────────────── + // Eager loading + // ───────────────────────────────────────────────────────────────────────── + + public function test_load_relation_hydrates_a_belongs_to_relation(): void + { + $category = $this->makeCategory('Tools'); + $widget = $this->makeWidget(['category_id' => $category->id]); + + $found = $this->repository + ->loadRelation(new Relationship(WidgetCategoryDomainObject::class, name: 'category')) + ->findById($widget->id); + + $this->assertNotNull($found->getCategory()); + $this->assertInstanceOf(WidgetCategoryDomainObject::class, $found->getCategory()); + $this->assertSame('Tools', $found->getCategory()->getName()); + } + + public function test_load_relation_hydrates_a_has_many_relation_as_a_collection(): void + { + $category = $this->makeCategory('Bolts'); + $this->makeWidget(['category_id' => $category->id, 'name' => 'M3']); + $this->makeWidget(['category_id' => $category->id, 'name' => 'M4']); + + $found = $this->categoryRepository + ->loadRelation(new Relationship(WidgetDomainObject::class, name: 'widgets')) + ->findById($category->id); + + $this->assertInstanceOf(Collection::class, $found->getWidgets()); + $this->assertCount(2, $found->getWidgets()); + } + + // ───────────────────────────────────────────────────────────────────────── + // Soft deletes / includeDeleted + // ───────────────────────────────────────────────────────────────────────── + + public function test_include_deleted_returns_soft_deleted_rows(): void + { + $widget = $this->makeWidget(); + $this->repository->deleteById($widget->id); + + $this->assertNull($this->repository->findFirstWhere(['id' => $widget->id])); + + $found = $this->repository->includeDeleted()->findFirstWhere(['id' => $widget->id]); + $this->assertNotNull($found); + $this->assertSame($widget->id, $found->getId()); + } + + // ───────────────────────────────────────────────────────────────────────── + // State reset (the actual point of the refactor) + // ───────────────────────────────────────────────────────────────────────── + + public function test_consecutive_finds_do_not_leak_where_clauses(): void + { + $a = $this->makeWidget(['is_active' => true]); + $b = $this->makeWidget(['is_active' => false]); + + // First call applies a where(is_active, true) + $first = $this->repository->findWhere(['is_active' => true]); + $this->assertCount(1, $first); + + // Second call must NOT inherit the previous where clause + $second = $this->repository->findWhere([]); + $this->assertCount(2, $second, 'Second findWhere([]) inherited state from the previous query'); + } + + public function test_eager_loads_are_reset_between_queries(): void + { + $category = $this->makeCategory('Cat'); + $widgetA = $this->makeWidget(['category_id' => $category->id]); + $widgetB = $this->makeWidget(['category_id' => $category->id]); + + $first = $this->repository + ->loadRelation(new Relationship(WidgetCategoryDomainObject::class, name: 'category')) + ->findById($widgetA->id); + $this->assertNotNull($first->getCategory()); + + // After the call, eagerLoads MUST be cleared. Previously this was a bug — + // resetModel() reset the builder but left $eagerLoads populated, so the + // array would grow unboundedly across calls on the same instance. + $this->assertSame([], $this->repository->exposeEagerLoads()); + + // A subsequent call without loadRelation() must produce an unhydrated relation. + $second = $this->repository->findById($widgetB->id); + $this->assertNull($second->getCategory()); + } + + public function test_state_is_reset_even_when_the_query_throws(): void + { + $this->makeWidget(['is_active' => true]); + + try { + // findById on a missing id throws ModelNotFoundException — but only + // AFTER the loadRelation call has registered an eager load and added + // a where clause. + $this->repository + ->loadRelation(new Relationship(WidgetCategoryDomainObject::class, name: 'category')) + ->findById(999_999); + $this->fail('Expected ModelNotFoundException'); + } catch (ModelNotFoundException) { + // expected + } + + // The next call on the same repository instance must start clean. + $this->assertSame([], $this->repository->exposeEagerLoads()); + $this->assertFalse($this->repository->exposeBuilderHasWheres()); + } + + public function test_set_max_per_page_caps_pagination_size(): void + { + for ($i = 0; $i < 10; $i++) { + $this->makeWidget(); + } + + $page = $this->repository->setMaxPerPage(3)->paginate(limit: 100); + + $this->assertCount(3, $page->items()); + } + + // ───────────────────────────────────────────────────────────────────────── + // Hydration edge cases + // ───────────────────────────────────────────────────────────────────────── + + public function test_hydration_calls_setters_via_studly_case(): void + { + // category_id is a snake_case column → setCategoryId on the domain object + $category = $this->makeCategory(); + $widget = $this->makeWidget(['category_id' => $category->id]); + + $found = $this->repository->findById($widget->id); + + $this->assertSame($category->id, $found->getCategoryId()); + } + + public function test_hydration_silently_skips_columns_with_no_setter(): void + { + // No setter exists on WidgetDomainObject for an unknown column. + // Add a column on the fly via raw SQL so the model picks it up. + Schema::table('br_test_widgets', function (Blueprint $table) { + $table->string('mystery_field')->nullable(); + }); + + $widget = $this->makeWidget(); + WidgetModel::query()->where('id', $widget->id)->update(['mystery_field' => 'something']); + + // Should not throw — the silent-skip behaviour is documented. + $found = $this->repository->findById($widget->id); + $this->assertNotNull($found); + } +} diff --git a/backend/tests/Feature/Repository/Eloquent/OrderItemRepositoryTest.php b/backend/tests/Feature/Repository/Eloquent/OrderItemRepositoryTest.php new file mode 100644 index 0000000000..5ab3478bae --- /dev/null +++ b/backend/tests/Feature/Repository/Eloquent/OrderItemRepositoryTest.php @@ -0,0 +1,272 @@ +withAccount() to bootstrap + * an account + user, then raw DB inserts for the rest of the FK chain so the test does not + * depend on factories that the codebase does not yet provide for organizers / events / + * products / orders. The DatabaseTransactions trait rolls everything back per test. + */ +class OrderItemRepositoryTest extends TestCase +{ + use DatabaseTransactions; + + private OrderItemRepository $repository; + + private int $eventId; + private int $occurrenceId; + private int $otherOccurrenceId; + private int $productId; + private int $productPriceId; + private int $accountId; + + protected function setUp(): void + { + parent::setUp(); + + $this->repository = $this->app->make(OrderItemRepository::class); + + // Bootstrap account + user via factory. + $user = User::factory()->withAccount()->create(); + $this->accountId = $user->accounts()->first()->id; + + $now = now()->toDateTimeString(); + + $organizerId = DB::table('organizers')->insertGetId([ + 'account_id' => $this->accountId, + 'name' => 'Test Organizer', + 'email' => 'organizer@example.test', + 'currency' => 'USD', + 'timezone' => 'UTC', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->eventId = DB::table('events')->insertGetId([ + 'title' => 'Test Event', + 'account_id' => $this->accountId, + 'user_id' => $user->id, + 'organizer_id' => $organizerId, + 'currency' => 'USD', + 'timezone' => 'UTC', + 'short_id' => 'test_evt_' . uniqid(), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->occurrenceId = DB::table('event_occurrences')->insertGetId([ + 'short_id' => 'occ_' . uniqid(), + 'event_id' => $this->eventId, + 'start_date' => now()->addDay()->toDateTimeString(), + 'end_date' => now()->addDays(1)->addHours(2)->toDateTimeString(), + 'status' => 'ACTIVE', + 'used_capacity' => 0, + 'is_overridden' => false, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->otherOccurrenceId = DB::table('event_occurrences')->insertGetId([ + 'short_id' => 'occ_' . uniqid(), + 'event_id' => $this->eventId, + 'start_date' => now()->addDays(2)->toDateTimeString(), + 'end_date' => now()->addDays(2)->addHours(2)->toDateTimeString(), + 'status' => 'ACTIVE', + 'used_capacity' => 0, + 'is_overridden' => false, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->productId = DB::table('products')->insertGetId([ + 'title' => 'Test Product', + 'event_id' => $this->eventId, + 'order' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->productPriceId = DB::table('product_prices')->insertGetId([ + 'product_id' => $this->productId, + 'price' => 10.00, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + public function testReturnsZeroWhenNoReservations(): void + { + $this->assertSame(0, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId)); + } + + public function testSumsActiveReservationsForOccurrence(): void + { + $this->insertOrderWithItems( + status: OrderStatus::RESERVED->name, + reservedUntil: now()->addHour(), + occurrenceQuantities: [$this->occurrenceId => 3], + ); + $this->insertOrderWithItems( + status: OrderStatus::RESERVED->name, + reservedUntil: now()->addMinutes(30), + occurrenceQuantities: [$this->occurrenceId => 2], + ); + + $this->assertSame(5, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId)); + } + + public function testIgnoresExpiredReservations(): void + { + $this->insertOrderWithItems( + status: OrderStatus::RESERVED->name, + reservedUntil: now()->subMinute(), + occurrenceQuantities: [$this->occurrenceId => 5], + ); + $this->insertOrderWithItems( + status: OrderStatus::RESERVED->name, + reservedUntil: now()->addHour(), + occurrenceQuantities: [$this->occurrenceId => 4], + ); + + $this->assertSame(4, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId)); + } + + public function testIgnoresNonReservedOrders(): void + { + $this->insertOrderWithItems( + status: OrderStatus::COMPLETED->name, + reservedUntil: now()->addHour(), + occurrenceQuantities: [$this->occurrenceId => 7], + ); + $this->insertOrderWithItems( + status: OrderStatus::CANCELLED->name, + reservedUntil: now()->addHour(), + occurrenceQuantities: [$this->occurrenceId => 9], + ); + $this->insertOrderWithItems( + status: OrderStatus::RESERVED->name, + reservedUntil: now()->addHour(), + occurrenceQuantities: [$this->occurrenceId => 1], + ); + + $this->assertSame(1, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId)); + } + + public function testIgnoresSoftDeletedOrders(): void + { + $this->insertOrderWithItems( + status: OrderStatus::RESERVED->name, + reservedUntil: now()->addHour(), + occurrenceQuantities: [$this->occurrenceId => 6], + deletedAt: now(), + ); + $this->insertOrderWithItems( + status: OrderStatus::RESERVED->name, + reservedUntil: now()->addHour(), + occurrenceQuantities: [$this->occurrenceId => 2], + ); + + $this->assertSame(2, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId)); + } + + public function testScopesByOccurrenceId(): void + { + $this->insertOrderWithItems( + status: OrderStatus::RESERVED->name, + reservedUntil: now()->addHour(), + occurrenceQuantities: [ + $this->occurrenceId => 3, + $this->otherOccurrenceId => 7, + ], + ); + + $this->assertSame(3, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId)); + $this->assertSame(7, $this->repository->getReservedQuantityForOccurrence($this->otherOccurrenceId)); + } + + public function testIgnoresSoftDeletedOrderItems(): void + { + $orderId = DB::table('orders')->insertGetId([ + 'short_id' => 'ord_' . uniqid(), + 'event_id' => $this->eventId, + 'currency' => 'USD', + 'status' => OrderStatus::RESERVED->name, + 'reserved_until' => now()->addHour(), + 'public_id' => 'pub_' . uniqid(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // One live item (counts), one soft-deleted item (does not). + DB::table('order_items')->insert([ + 'order_id' => $orderId, + 'product_id' => $this->productId, + 'product_price_id' => $this->productPriceId, + 'event_occurrence_id' => $this->occurrenceId, + 'quantity' => 4, + 'price' => 10.00, + 'total_before_additions' => 40.00, + ]); + + DB::table('order_items')->insert([ + 'order_id' => $orderId, + 'product_id' => $this->productId, + 'product_price_id' => $this->productPriceId, + 'event_occurrence_id' => $this->occurrenceId, + 'quantity' => 6, + 'price' => 10.00, + 'total_before_additions' => 60.00, + 'deleted_at' => now(), + ]); + + $this->assertSame(4, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId)); + } + + /** + * @param array $occurrenceQuantities Map of event_occurrence_id => quantity + */ + private function insertOrderWithItems( + string $status, + \DateTimeInterface $reservedUntil, + array $occurrenceQuantities, + ?\DateTimeInterface $deletedAt = null, + ): int { + $orderId = DB::table('orders')->insertGetId([ + 'short_id' => 'ord_' . uniqid(), + 'event_id' => $this->eventId, + 'currency' => 'USD', + 'status' => $status, + 'reserved_until' => $reservedUntil, + 'public_id' => 'pub_' . uniqid(), + 'created_at' => now(), + 'updated_at' => now(), + 'deleted_at' => $deletedAt, + ]); + + foreach ($occurrenceQuantities as $occurrenceId => $quantity) { + DB::table('order_items')->insert([ + 'order_id' => $orderId, + 'product_id' => $this->productId, + 'product_price_id' => $this->productPriceId, + 'event_occurrence_id' => $occurrenceId, + 'quantity' => $quantity, + 'price' => 10.00, + 'total_before_additions' => $quantity * 10.00, + ]); + } + + return $orderId; + } +} diff --git a/backend/tests/Feature/Repository/Fixtures/WidgetCategoryDomainObject.php b/backend/tests/Feature/Repository/Fixtures/WidgetCategoryDomainObject.php new file mode 100644 index 0000000000..c71558aec2 --- /dev/null +++ b/backend/tests/Feature/Repository/Fixtures/WidgetCategoryDomainObject.php @@ -0,0 +1,65 @@ +id = $id; + + return $this; + } + + public function getId(): ?int + { + return $this->id; + } + + public function setName(?string $name): self + { + $this->name = $name; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setWidgets(?Collection $widgets): self + { + $this->widgets = $widgets; + + return $this; + } + + public function getWidgets(): ?Collection + { + return $this->widgets; + } + + public function toArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + ]; + } +} diff --git a/backend/tests/Feature/Repository/Fixtures/WidgetCategoryModel.php b/backend/tests/Feature/Repository/Fixtures/WidgetCategoryModel.php new file mode 100644 index 0000000000..08c57c97f5 --- /dev/null +++ b/backend/tests/Feature/Repository/Fixtures/WidgetCategoryModel.php @@ -0,0 +1,23 @@ +hasMany(WidgetModel::class, 'category_id'); + } +} diff --git a/backend/tests/Feature/Repository/Fixtures/WidgetCategoryRepository.php b/backend/tests/Feature/Repository/Fixtures/WidgetCategoryRepository.php new file mode 100644 index 0000000000..ff7f7520ed --- /dev/null +++ b/backend/tests/Feature/Repository/Fixtures/WidgetCategoryRepository.php @@ -0,0 +1,23 @@ + + */ +class WidgetCategoryRepository extends BaseRepository +{ + protected function getModel(): string + { + return WidgetCategoryModel::class; + } + + public function getDomainObject(): string + { + return WidgetCategoryDomainObject::class; + } +} diff --git a/backend/tests/Feature/Repository/Fixtures/WidgetDomainObject.php b/backend/tests/Feature/Repository/Fixtures/WidgetDomainObject.php new file mode 100644 index 0000000000..c673e4470a --- /dev/null +++ b/backend/tests/Feature/Repository/Fixtures/WidgetDomainObject.php @@ -0,0 +1,199 @@ +id = $id; + + return $this; + } + + public function getId(): ?int + { + return $this->id; + } + + public function setCategoryId(?int $category_id): self + { + $this->category_id = $category_id; + + return $this; + } + + public function getCategoryId(): ?int + { + return $this->category_id; + } + + public function setName(?string $name): self + { + $this->name = $name; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setSku(?string $sku): self + { + $this->sku = $sku; + + return $this; + } + + public function getSku(): ?string + { + return $this->sku; + } + + public function setQuantity(?int $quantity): self + { + $this->quantity = $quantity; + + return $this; + } + + public function getQuantity(): ?int + { + return $this->quantity; + } + + public function setPrice(float|int|null $price): self + { + $this->price = $price === null ? null : (float) $price; + + return $this; + } + + public function getPrice(): ?float + { + return $this->price; + } + + public function setIsActive(?bool $is_active): self + { + $this->is_active = $is_active; + + return $this; + } + + public function getIsActive(): ?bool + { + return $this->is_active; + } + + public function setDescription(?string $description): self + { + $this->description = $description; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } + + public function setCategory(?WidgetCategoryDomainObject $category): self + { + $this->category = $category; + + return $this; + } + + public function getCategory(): ?WidgetCategoryDomainObject + { + return $this->category; + } + + public function toArray(): array + { + return [ + 'id' => $this->id, + 'category_id' => $this->category_id, + 'name' => $this->name, + 'sku' => $this->sku, + 'quantity' => $this->quantity, + 'price' => $this->price, + 'is_active' => $this->is_active, + 'description' => $this->description, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + 'deleted_at' => $this->deleted_at, + ]; + } +} diff --git a/backend/tests/Feature/Repository/Fixtures/WidgetModel.php b/backend/tests/Feature/Repository/Fixtures/WidgetModel.php new file mode 100644 index 0000000000..387a657ef3 --- /dev/null +++ b/backend/tests/Feature/Repository/Fixtures/WidgetModel.php @@ -0,0 +1,43 @@ + 'boolean', + 'quantity' => 'integer', + 'price' => 'float', + ]; + } + + public function category(): BelongsTo + { + return $this->belongsTo(WidgetCategoryModel::class, 'category_id'); + } +} diff --git a/backend/tests/Feature/Repository/Fixtures/WidgetRepository.php b/backend/tests/Feature/Repository/Fixtures/WidgetRepository.php new file mode 100644 index 0000000000..6afa6610f9 --- /dev/null +++ b/backend/tests/Feature/Repository/Fixtures/WidgetRepository.php @@ -0,0 +1,44 @@ + + */ +class WidgetRepository extends BaseRepository +{ + protected function getModel(): string + { + return WidgetModel::class; + } + + public function getDomainObject(): string + { + return WidgetDomainObject::class; + } + + /** + * Test hooks: expose protected state so we can assert reset behaviour + * without resorting to reflection. + */ + public function exposeEagerLoads(): array + { + return $this->eagerLoads; + } + + public function exposeBuilderHasWheres(): bool + { + $base = $this->model->getQuery(); + + return ! empty($base->wheres); + } +} diff --git a/backend/tests/Unit/DomainObjects/EventDomainObjectTest.php b/backend/tests/Unit/DomainObjects/EventDomainObjectTest.php new file mode 100644 index 0000000000..81fab8f06b --- /dev/null +++ b/backend/tests/Unit/DomainObjects/EventDomainObjectTest.php @@ -0,0 +1,297 @@ +setStartDate($startDate); + $occurrence->setEndDate($endDate); + $occurrence->setStatus($status); + + return $occurrence; + } + + private function createEvent(?Collection $occurrences = null, ?string $timezone = null): EventDomainObject + { + $event = new EventDomainObject(); + + if ($occurrences !== null) { + $event->setEventOccurrences($occurrences); + } + + if ($timezone !== null) { + $event->setTimezone($timezone); + } + + return $event; + } + + public function testGetStartDateReturnsEarliestOccurrenceStartDate(): void + { + $earlier = Carbon::now()->subDays(3)->toDateTimeString(); + $later = Carbon::now()->subDay()->toDateTimeString(); + + $occurrences = collect([ + $this->createOccurrence($later), + $this->createOccurrence($earlier), + ]); + + $event = $this->createEvent($occurrences); + + $this->assertEquals($earlier, $event->getStartDate()); + } + + public function testGetStartDateReturnsNullWhenNoOccurrences(): void + { + $event = $this->createEvent(); + $this->assertNull($event->getStartDate()); + + $eventWithEmpty = $this->createEvent(collect([])); + $this->assertNull($eventWithEmpty->getStartDate()); + } + + public function testGetEndDateReturnsLatestOccurrenceEndDate(): void + { + $earlierEnd = Carbon::now()->addDay()->toDateTimeString(); + $laterEnd = Carbon::now()->addDays(3)->toDateTimeString(); + + $occurrences = collect([ + $this->createOccurrence( + Carbon::now()->subDay()->toDateTimeString(), + $earlierEnd, + ), + $this->createOccurrence( + Carbon::now()->toDateTimeString(), + $laterEnd, + ), + ]); + + $event = $this->createEvent($occurrences); + + $this->assertEquals($laterEnd, $event->getEndDate()); + } + + public function testGetEndDateFallsBackToLatestStartDateWhenNoEndDates(): void + { + $earlierStart = Carbon::now()->subDay()->toDateTimeString(); + $laterStart = Carbon::now()->addDay()->toDateTimeString(); + + $occurrences = collect([ + $this->createOccurrence($earlierStart), + $this->createOccurrence($laterStart), + ]); + + $event = $this->createEvent($occurrences); + + $this->assertEquals($laterStart, $event->getEndDate()); + } + + public function testGetEndDateReturnsNullWhenNoOccurrences(): void + { + $event = $this->createEvent(); + $this->assertNull($event->getEndDate()); + + $eventWithEmpty = $this->createEvent(collect([])); + $this->assertNull($eventWithEmpty->getEndDate()); + } + + public function testIsEventInPastReturnsTrueWhenAllOccurrencesArePast(): void + { + $occurrences = collect([ + $this->createOccurrence( + Carbon::now()->subDays(3)->toDateTimeString(), + Carbon::now()->subDays(2)->toDateTimeString(), + ), + $this->createOccurrence( + Carbon::now()->subDays(2)->toDateTimeString(), + Carbon::now()->subDay()->toDateTimeString(), + ), + ]); + + $event = $this->createEvent($occurrences); + + $this->assertTrue($event->isEventInPast()); + } + + public function testIsEventInPastReturnsFalseWhenSomeOccurrencesAreFuture(): void + { + $occurrences = collect([ + $this->createOccurrence( + Carbon::now()->subDays(2)->toDateTimeString(), + Carbon::now()->subDay()->toDateTimeString(), + ), + $this->createOccurrence( + Carbon::now()->addDay()->toDateTimeString(), + Carbon::now()->addDays(2)->toDateTimeString(), + ), + ]); + + $event = $this->createEvent($occurrences); + + $this->assertFalse($event->isEventInPast()); + } + + public function testIsEventInFutureReturnsTrueWhenEarliestStartIsFuture(): void + { + $occurrences = collect([ + $this->createOccurrence( + Carbon::now()->addDay()->toDateTimeString(), + Carbon::now()->addDays(2)->toDateTimeString(), + ), + $this->createOccurrence( + Carbon::now()->addDays(3)->toDateTimeString(), + Carbon::now()->addDays(4)->toDateTimeString(), + ), + ]); + + $event = $this->createEvent($occurrences); + + $this->assertTrue($event->isEventInFuture()); + } + + public function testIsEventInFutureReturnsFalseWhenEarliestStartIsPast(): void + { + $occurrences = collect([ + $this->createOccurrence( + Carbon::now()->subDay()->toDateTimeString(), + Carbon::now()->addDay()->toDateTimeString(), + ), + $this->createOccurrence( + Carbon::now()->addDays(2)->toDateTimeString(), + Carbon::now()->addDays(3)->toDateTimeString(), + ), + ]); + + $event = $this->createEvent($occurrences); + + $this->assertFalse($event->isEventInFuture()); + } + + public function testIsEventOngoingReturnsTrueWhenActiveOccurrenceHasStartedButNotEnded(): void + { + $occurrences = collect([ + $this->createOccurrence( + Carbon::now()->subHour()->toDateTimeString(), + Carbon::now()->addHour()->toDateTimeString(), + EventOccurrenceStatus::ACTIVE->name, + ), + ]); + + $event = $this->createEvent($occurrences); + + $this->assertTrue($event->isEventOngoing()); + } + + public function testIsEventOngoingReturnsFalseForCancelledOccurrences(): void + { + $occurrences = collect([ + $this->createOccurrence( + Carbon::now()->subHour()->toDateTimeString(), + Carbon::now()->addHour()->toDateTimeString(), + EventOccurrenceStatus::CANCELLED->name, + ), + ]); + + $event = $this->createEvent($occurrences); + + $this->assertFalse($event->isEventOngoing()); + } + + public function testIsEventOngoingReturnsTrueWhenActiveOccurrenceHasNoEndDate(): void + { + $occurrences = collect([ + $this->createOccurrence( + Carbon::now()->subHour()->toDateTimeString(), + null, + EventOccurrenceStatus::ACTIVE->name, + ), + ]); + + $event = $this->createEvent($occurrences); + + $this->assertTrue($event->isEventOngoing()); + } + + public function testIsEventOngoingReturnsFalseWhenNoOccurrences(): void + { + $event = $this->createEvent(); + $this->assertFalse($event->isEventOngoing()); + + $eventWithEmpty = $this->createEvent(collect([])); + $this->assertFalse($eventWithEmpty->isEventOngoing()); + } + + public function testGetLifecycleStatusReturnsOngoingWhenOngoing(): void + { + $occurrences = collect([ + $this->createOccurrence( + Carbon::now()->subHour()->toDateTimeString(), + Carbon::now()->addHour()->toDateTimeString(), + EventOccurrenceStatus::ACTIVE->name, + ), + ]); + + $event = $this->createEvent($occurrences); + + $this->assertEquals(EventLifecycleStatus::ONGOING->name, $event->getLifecycleStatus()); + } + + public function testGetLifecycleStatusReturnsUpcomingWhenAllFuture(): void + { + $occurrences = collect([ + $this->createOccurrence( + Carbon::now()->addDay()->toDateTimeString(), + Carbon::now()->addDays(2)->toDateTimeString(), + ), + ]); + + $event = $this->createEvent($occurrences); + + $this->assertEquals(EventLifecycleStatus::UPCOMING->name, $event->getLifecycleStatus()); + } + + public function testGetLifecycleStatusReturnsEndedWhenAllPast(): void + { + $occurrences = collect([ + $this->createOccurrence( + Carbon::now()->subDays(3)->toDateTimeString(), + Carbon::now()->subDay()->toDateTimeString(), + ), + ]); + + $event = $this->createEvent($occurrences); + + $this->assertEquals(EventLifecycleStatus::ENDED->name, $event->getLifecycleStatus()); + } + + public function testIsRecurringReturnsTrueForRecurringType(): void + { + $event = new EventDomainObject(); + $event->setType(EventType::RECURRING->name); + + $this->assertTrue($event->isRecurring()); + } + + public function testIsRecurringReturnsFalseForSingleType(): void + { + $event = new EventDomainObject(); + $event->setType(EventType::SINGLE->name); + + $this->assertFalse($event->isRecurring()); + } +} diff --git a/backend/tests/Unit/Jobs/Occurrence/BulkCancelOccurrencesJobTest.php b/backend/tests/Unit/Jobs/Occurrence/BulkCancelOccurrencesJobTest.php new file mode 100644 index 0000000000..82679abb1b --- /dev/null +++ b/backend/tests/Unit/Jobs/Occurrence/BulkCancelOccurrencesJobTest.php @@ -0,0 +1,166 @@ +andReturnUsing(fn ($callback) => $callback()); + + $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->exclusionService = Mockery::mock(RecurrenceRuleExclusionService::class); + $this->cancelAttendeesService = Mockery::mock(CancelOccurrenceAttendeesService::class); + $this->cancelAttendeesService->shouldReceive('cancelForOccurrence')->byDefault(); + $this->exclusionService->shouldReceive('addExclusions')->byDefault(); + } + + public function test_handle_cancels_multiple_occurrences(): void + { + Log::shouldReceive('info')->once(); + + $occ1 = Mockery::mock(EventOccurrenceDomainObject::class); + $occ1->shouldReceive('getEventId')->andReturn(1); + $occ1->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name); + $occ1->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00'); + + $occ2 = Mockery::mock(EventOccurrenceDomainObject::class); + $occ2->shouldReceive('getEventId')->andReturn(1); + $occ2->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name); + $occ2->shouldReceive('getStartDate')->andReturn('2026-06-22 10:00:00'); + + $this->occurrenceRepository->shouldReceive('findByIdLocked') + ->with(10) + ->once() + ->andReturn($occ1); + $this->occurrenceRepository->shouldReceive('findByIdLocked') + ->with(20) + ->once() + ->andReturn($occ2); + + $this->occurrenceRepository->shouldReceive('updateWhere')->twice(); + + $this->exclusionService + ->shouldReceive('addExclusions') + ->once() + ->with(1, ['2026-06-15 10:00:00', '2026-06-22 10:00:00']); + + $job = new BulkCancelOccurrencesJob(1, [10, 20]); + $job->handle($this->occurrenceRepository, $this->exclusionService, $this->cancelAttendeesService); + + Event::assertDispatchedTimes(OccurrenceCancelledEvent::class, 2); + } + + public function test_handle_skips_already_cancelled_occurrences(): void + { + Log::shouldReceive('info')->once(); + + $occ = Mockery::mock(EventOccurrenceDomainObject::class); + $occ->shouldReceive('getEventId')->andReturn(1); + $occ->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::CANCELLED->name); + + $this->occurrenceRepository->shouldReceive('findByIdLocked') + ->with(10) + ->once() + ->andReturn($occ); + $this->occurrenceRepository->shouldNotReceive('updateWhere'); + $this->exclusionService->shouldNotReceive('addExclusions'); + + $job = new BulkCancelOccurrencesJob(1, [10]); + $job->handle($this->occurrenceRepository, $this->exclusionService, $this->cancelAttendeesService); + + Event::assertNotDispatched(OccurrenceCancelledEvent::class); + } + + public function test_it_skips_occurrences_not_belonging_to_event(): void + { + Log::shouldReceive('info')->once(); + + $foreignOccurrence = Mockery::mock(EventOccurrenceDomainObject::class); + $foreignOccurrence->shouldReceive('getEventId')->andReturn(99); + + $this->occurrenceRepository->shouldReceive('findByIdLocked') + ->with(10) + ->once() + ->andReturn($foreignOccurrence); + + $this->occurrenceRepository->shouldNotReceive('updateWhere'); + $this->exclusionService->shouldNotReceive('addExclusions'); + + $job = new BulkCancelOccurrencesJob(1, [10]); + $job->handle($this->occurrenceRepository, $this->exclusionService, $this->cancelAttendeesService); + + Event::assertNotDispatched(OccurrenceCancelledEvent::class); + } + + public function test_handle_dispatches_event_with_refund_flag_true(): void + { + Log::shouldReceive('info')->once(); + + $occ = Mockery::mock(EventOccurrenceDomainObject::class); + $occ->shouldReceive('getEventId')->andReturn(1); + $occ->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name); + $occ->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00'); + + $this->occurrenceRepository->shouldReceive('findByIdLocked') + ->with(10) + ->once() + ->andReturn($occ); + $this->occurrenceRepository->shouldReceive('updateWhere')->once(); + + $job = new BulkCancelOccurrencesJob(1, [10], refundOrders: true); + $job->handle($this->occurrenceRepository, $this->exclusionService, $this->cancelAttendeesService); + + Event::assertDispatched(OccurrenceCancelledEvent::class, fn ($e) => $e->occurrenceId === 10 && $e->refundOrders === true); + } + + public function test_handle_dispatches_event_with_refund_flag_false(): void + { + Log::shouldReceive('info')->once(); + + $occ = Mockery::mock(EventOccurrenceDomainObject::class); + $occ->shouldReceive('getEventId')->andReturn(1); + $occ->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name); + $occ->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00'); + + $this->occurrenceRepository->shouldReceive('findByIdLocked') + ->with(10) + ->once() + ->andReturn($occ); + $this->occurrenceRepository->shouldReceive('updateWhere')->once(); + + $job = new BulkCancelOccurrencesJob(1, [10], refundOrders: false); + $job->handle($this->occurrenceRepository, $this->exclusionService, $this->cancelAttendeesService); + + Event::assertDispatched(OccurrenceCancelledEvent::class, fn ($e) => $e->refundOrders === false); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Jobs/Occurrence/RefundOccurrenceOrdersJobTest.php b/backend/tests/Unit/Jobs/Occurrence/RefundOccurrenceOrdersJobTest.php new file mode 100644 index 0000000000..47bc59bb43 --- /dev/null +++ b/backend/tests/Unit/Jobs/Occurrence/RefundOccurrenceOrdersJobTest.php @@ -0,0 +1,213 @@ +refundHandler = Mockery::mock(RefundOrderHandler::class); + $this->auditLogRepository = Mockery::mock(OrderAuditLogRepositoryInterface::class); + } + + private function mockDbChain(int $occurrenceId, array $orderIds, array $refundableOrders, array $multiOccurrenceOrderIds = []): void + { + $orderItemsBuilder = Mockery::mock('orderItemsBuilder'); + $ordersBuilder = Mockery::mock('ordersBuilder'); + $batchBuilder = Mockery::mock('batchBuilder'); + + // First call: get order_ids for occurrence + // Third call: batch multi-occurrence check + DB::shouldReceive('table')->with('order_items')->andReturn($orderItemsBuilder, $batchBuilder); + DB::shouldReceive('table')->with('orders')->andReturn($ordersBuilder); + + // First order_items query: get order IDs + $orderItemsBuilder->shouldReceive('where')->with('event_occurrence_id', $occurrenceId)->andReturnSelf(); + $orderItemsBuilder->shouldReceive('whereNull')->with('deleted_at')->andReturnSelf(); + $orderItemsBuilder->shouldReceive('distinct')->andReturnSelf(); + $orderItemsBuilder->shouldReceive('pluck')->with('order_id')->andReturn(collect($orderIds)); + + // Orders query: already-refunded rows are filtered out with whereNull('refund_status') + $ordersBuilder->shouldReceive('whereIn')->with('id', Mockery::any())->andReturnSelf(); + $ordersBuilder->shouldReceive('where')->with('status', 'COMPLETED')->andReturnSelf(); + $ordersBuilder->shouldReceive('where')->with('payment_status', 'PAYMENT_RECEIVED')->andReturnSelf(); + $ordersBuilder->shouldReceive('whereNull')->with('refund_status')->andReturnSelf(); + $ordersBuilder->shouldReceive('get')->with(['id', 'total_gross', 'currency'])->andReturn( + collect(array_map(fn ($o) => (object) $o, $refundableOrders)) + ); + + // Batch multi-occurrence check + $batchBuilder->shouldReceive('whereIn')->andReturnSelf(); + $batchBuilder->shouldReceive('whereNull')->andReturnSelf(); + $batchBuilder->shouldReceive('select')->andReturnSelf(); + $batchBuilder->shouldReceive('groupBy')->andReturnSelf(); + $batchBuilder->shouldReceive('havingRaw')->andReturnSelf(); + $batchBuilder->shouldReceive('pluck')->with('order_id')->andReturn(collect($multiOccurrenceOrderIds)); + } + + public function test_handle_refunds_single_occurrence_orders(): void + { + $this->mockDbChain( + occurrenceId: 10, + orderIds: [100], + refundableOrders: [['id' => 100, 'total_gross' => 50.00, 'currency' => 'USD']], + multiOccurrenceOrderIds: [], + ); + + $this->refundHandler + ->shouldReceive('handle') + ->once() + ->with(Mockery::on(fn (RefundOrderDTO $dto) => $dto->event_id === 1 + && $dto->order_id === 100 + && $dto->amount === 50.00 + && $dto->notify_buyer === true + && $dto->cancel_order === true + )); + + $job = new RefundOccurrenceOrdersJob(1, 10); + $job->handle($this->refundHandler, $this->auditLogRepository); + + $this->assertTrue(true); + } + + public function test_handle_skips_multi_occurrence_orders(): void + { + Log::shouldReceive('warning')->once(); + + $this->mockDbChain( + occurrenceId: 10, + orderIds: [100], + refundableOrders: [['id' => 100, 'total_gross' => 50.00, 'currency' => 'USD']], + multiOccurrenceOrderIds: [100], + ); + + $this->refundHandler->shouldNotReceive('handle'); + + // Skipped orders must leave a trail on the order's audit log so the + // admin can see which orders need manual refunds. + $this->auditLogRepository + ->shouldReceive('create') + ->once() + ->with(Mockery::on(fn (array $attributes) => $attributes['order_id'] === 100 + && $attributes['event_id'] === 1 + && $attributes['action'] === OrderAuditAction::AUTOMATIC_REFUND_SKIPPED->value + && $attributes['new_values']['cancelled_occurrence_id'] === 10)); + + $job = new RefundOccurrenceOrdersJob(1, 10); + $job->handle($this->refundHandler, $this->auditLogRepository); + + $this->assertTrue(true); + } + + public function test_handle_returns_early_when_no_order_items(): void + { + $builder = Mockery::mock('builder'); + DB::shouldReceive('table')->with('order_items')->andReturn($builder); + $builder->shouldReceive('where')->with('event_occurrence_id', 10)->andReturnSelf(); + $builder->shouldReceive('whereNull')->with('deleted_at')->andReturnSelf(); + $builder->shouldReceive('distinct')->andReturnSelf(); + $builder->shouldReceive('pluck')->with('order_id')->andReturn(collect()); + + $this->refundHandler->shouldNotReceive('handle'); + + $job = new RefundOccurrenceOrdersJob(1, 10); + $job->handle($this->refundHandler, $this->auditLogRepository); + + $this->assertTrue(true); + } + + public function test_handle_skips_orders_already_refunded(): void + { + // whereNull('refund_status') filters out orders that already entered a refund + // workflow. The mocked "refundableOrders" collection is empty because the + // filter excluded them — the handler must not be called. + $this->mockDbChain( + occurrenceId: 10, + orderIds: [100, 101], + refundableOrders: [], + multiOccurrenceOrderIds: [], + ); + + $this->refundHandler->shouldNotReceive('handle'); + + $job = new RefundOccurrenceOrdersJob(1, 10); + $job->handle($this->refundHandler, $this->auditLogRepository); + + $this->assertTrue(true); + } + + public function test_unique_id_is_occurrence_scoped(): void + { + $job = new RefundOccurrenceOrdersJob(1, 10); + $this->assertSame('occurrence:10', $job->uniqueId()); + } + + public function test_handle_continues_on_refund_error_and_writes_audit_log(): void + { + Log::shouldReceive('error')->once(); + + $this->mockDbChain( + occurrenceId: 10, + orderIds: [100], + refundableOrders: [['id' => 100, 'total_gross' => 50.00, 'currency' => 'USD']], + multiOccurrenceOrderIds: [], + ); + + $this->refundHandler + ->shouldReceive('handle') + ->once() + ->andThrow(new \RuntimeException('Stripe error')); + + // Failed refunds must surface on the order's audit log so admins can see + // which orders need manual intervention. + $this->auditLogRepository + ->shouldReceive('create') + ->once() + ->with(Mockery::on(fn (array $attributes) => $attributes['order_id'] === 100 + && $attributes['event_id'] === 1 + && $attributes['action'] === OrderAuditAction::AUTOMATIC_REFUND_FAILED->value + && $attributes['new_values']['cancelled_occurrence_id'] === 10 + && $attributes['new_values']['error'] === 'Stripe error')); + + $job = new RefundOccurrenceOrdersJob(1, 10); + $job->handle($this->refundHandler, $this->auditLogRepository); + + $this->assertTrue(true); + } + + public function test_failed_logs_critical(): void + { + Log::shouldReceive('critical') + ->once() + ->with('RefundOccurrenceOrdersJob permanently failed after retries', Mockery::on(fn (array $context) => $context['event_id'] === 1 + && $context['occurrence_id'] === 10 + && $context['error'] === 'queue exhausted')); + + $job = new RefundOccurrenceOrdersJob(1, 10); + $job->failed(new \RuntimeException('queue exhausted')); + + $this->assertTrue(true); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Jobs/Occurrence/SendOccurrenceCancellationEmailJobTest.php b/backend/tests/Unit/Jobs/Occurrence/SendOccurrenceCancellationEmailJobTest.php new file mode 100644 index 0000000000..5ee544b81d --- /dev/null +++ b/backend/tests/Unit/Jobs/Occurrence/SendOccurrenceCancellationEmailJobTest.php @@ -0,0 +1,186 @@ +eventRepository = Mockery::mock(EventRepositoryInterface::class); + $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class); + $this->mailer = Mockery::mock(Mailer::class); + $this->mailBuilderService = Mockery::mock(MailBuilderService::class); + $this->mailBuilderService->shouldReceive('buildOccurrenceCancellationMail') + ->andReturn(Mockery::mock(OccurrenceCancellationMail::class)); + } + + private function makeEvent(): EventDomainObject|Mockery\MockInterface + { + $organizer = Mockery::mock(OrganizerDomainObject::class); + $eventSettings = Mockery::mock(EventSettingDomainObject::class); + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getTimezone')->andReturn('America/New_York'); + $event->shouldReceive('getOrganizer')->andReturn($organizer); + $event->shouldReceive('getEventSettings')->andReturn($eventSettings); + + return $event; + } + + private function makeAttendee(string $email, string $locale = 'en'): AttendeeDomainObject|Mockery\MockInterface + { + $attendee = Mockery::mock(AttendeeDomainObject::class); + $attendee->shouldReceive('getEmail')->andReturn($email); + $attendee->shouldReceive('getLocale')->andReturn($locale); + + return $attendee; + } + + private function setupCommon(array $attendees): void + { + $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); + $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 14:00:00'); + + $this->occurrenceRepository->shouldReceive('findById')->with(10)->once()->andReturn($occurrence); + $this->eventRepository->shouldReceive('loadRelation')->twice()->andReturnSelf(); + $this->eventRepository->shouldReceive('findById')->with(1)->once()->andReturn($this->makeEvent()); + $this->attendeeRepository->shouldReceive('findWhere')->once()->andReturn(collect($attendees)); + } + + public function test_handle_sends_email_to_each_unique_attendee(): void + { + $this->setupCommon([ + $this->makeAttendee('alice@example.com'), + $this->makeAttendee('bob@example.com'), + ]); + + $emailsSent = []; + $pendingMail = Mockery::mock(PendingMail::class); + $pendingMail->shouldReceive('locale')->andReturnSelf(); + $pendingMail->shouldReceive('send')->with(Mockery::type(OccurrenceCancellationMail::class)); + + $this->mailer->shouldReceive('to')->andReturnUsing(function ($email) use (&$emailsSent, $pendingMail) { + $emailsSent[] = $email; + + return $pendingMail; + }); + + $job = new SendOccurrenceCancellationEmailJob(1, 10); + $job->handle($this->eventRepository, $this->occurrenceRepository, $this->attendeeRepository, $this->mailer, $this->mailBuilderService); + + $this->assertCount(2, $emailsSent); + $this->assertContains('alice@example.com', $emailsSent); + $this->assertContains('bob@example.com', $emailsSent); + } + + public function test_handle_deduplicates_by_email(): void + { + $this->setupCommon([ + $this->makeAttendee('same@example.com'), + $this->makeAttendee('same@example.com'), + ]); + + $emailsSent = []; + $pendingMail = Mockery::mock(PendingMail::class); + $pendingMail->shouldReceive('locale')->andReturnSelf(); + $pendingMail->shouldReceive('send'); + + $this->mailer->shouldReceive('to')->andReturnUsing(function ($email) use (&$emailsSent, $pendingMail) { + $emailsSent[] = $email; + + return $pendingMail; + }); + + $job = new SendOccurrenceCancellationEmailJob(1, 10); + $job->handle($this->eventRepository, $this->occurrenceRepository, $this->attendeeRepository, $this->mailer, $this->mailBuilderService); + + $this->assertCount(1, $emailsSent); + } + + public function test_handle_does_not_filter_out_cancelled_attendees(): void + { + // Regression guard: CancelOccurrenceHandler marks attendees CANCELLED + // inside the transaction that fires this job's event. If this query + // re-introduces a status filter it will silently exclude the very + // attendees we need to notify. + $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); + $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 14:00:00'); + + $this->occurrenceRepository->shouldReceive('findById')->once()->andReturn($occurrence); + $this->eventRepository->shouldReceive('loadRelation')->twice()->andReturnSelf(); + $this->eventRepository->shouldReceive('findById')->once()->andReturn($this->makeEvent()); + + $this->attendeeRepository + ->shouldReceive('findWhere') + ->once() + ->with(Mockery::on(function (array $where) { + // Exactly one condition: the occurrence filter. No status clause. + return array_keys($where) === ['event_occurrence_id'] + && $where['event_occurrence_id'] === 10; + })) + ->andReturn(collect([$this->makeAttendee('cancelled@example.com')])); + + $pendingMail = Mockery::mock(PendingMail::class); + $pendingMail->shouldReceive('locale')->andReturnSelf(); + $pendingMail->shouldReceive('send'); + $this->mailer->shouldReceive('to')->andReturn($pendingMail); + + $job = new SendOccurrenceCancellationEmailJob(1, 10); + $job->handle($this->eventRepository, $this->occurrenceRepository, $this->attendeeRepository, $this->mailer, $this->mailBuilderService); + + $this->assertTrue(true); + } + + public function test_handle_returns_early_when_no_attendees(): void + { + $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); + $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 14:00:00'); + + $this->occurrenceRepository->shouldReceive('findById')->once()->andReturn($occurrence); + $this->eventRepository->shouldReceive('loadRelation')->twice()->andReturnSelf(); + $this->eventRepository->shouldReceive('findById')->once()->andReturn($this->makeEvent()); + $this->attendeeRepository->shouldReceive('findWhere')->once()->andReturn(collect()); + + $this->mailer->shouldNotReceive('to'); + + $job = new SendOccurrenceCancellationEmailJob(1, 10); + $job->handle($this->eventRepository, $this->occurrenceRepository, $this->attendeeRepository, $this->mailer, $this->mailBuilderService); + + $this->assertTrue(true); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Jobs/Vat/ValidateVatNumberJobTest.php b/backend/tests/Unit/Jobs/Vat/ValidateVatNumberJobTest.php deleted file mode 100644 index 1a91aa3564..0000000000 --- a/backend/tests/Unit/Jobs/Vat/ValidateVatNumberJobTest.php +++ /dev/null @@ -1,154 +0,0 @@ -viesService = Mockery::mock(ViesValidationService::class); - $this->repository = Mockery::mock(AccountVatSettingRepositoryInterface::class); - $this->logger = Mockery::mock(LoggerInterface::class); - $this->logger->shouldReceive('info')->byDefault(); - $this->logger->shouldReceive('warning')->byDefault(); - } - - public function testJobUpdatesSettingsOnSuccessfulValidation(): void - { - $accountVatSettingId = 123; - $vatNumber = 'IE1234567A'; - - $job = new ValidateVatNumberJob($accountVatSettingId, $vatNumber); - - $validationResponse = new ViesValidationResponseDTO( - valid: true, - businessName: 'Test Company Ltd', - businessAddress: '123 Test Street', - countryCode: 'IE', - vatNumber: '1234567A', - isTransientError: false, - ); - - $domainObject = Mockery::mock(AccountVatSettingDomainObject::class); - - $this->viesService - ->shouldReceive('validateVatNumber') - ->with($vatNumber) - ->once() - ->andReturn($validationResponse); - - $this->repository - ->shouldReceive('updateFromArray') - ->twice() - ->withArgs(function ($id, $data) use ($accountVatSettingId) { - if ($id !== $accountVatSettingId) { - return false; - } - - if (isset($data['vat_validation_status']) && $data['vat_validation_status'] === VatValidationStatus::VALIDATING->value) { - return true; - } - - if (isset($data['vat_validated']) && $data['vat_validated'] === true) { - return $data['vat_validation_status'] === VatValidationStatus::VALID->value - && $data['business_name'] === 'Test Company Ltd'; - } - - return false; - }) - ->andReturn($domainObject); - - $job->handle($this->viesService, $this->repository, $this->logger); - - $this->assertTrue(true); - } - - public function testJobUpdatesSettingsOnInvalidVatNumber(): void - { - $accountVatSettingId = 123; - $vatNumber = 'IE9999999ZZ'; - - $job = new ValidateVatNumberJob($accountVatSettingId, $vatNumber); - - $validationResponse = new ViesValidationResponseDTO( - valid: false, - countryCode: 'IE', - vatNumber: '9999999ZZ', - isTransientError: false, - errorMessage: 'VAT number is not valid', - ); - - $domainObject = Mockery::mock(AccountVatSettingDomainObject::class); - - $this->viesService - ->shouldReceive('validateVatNumber') - ->with($vatNumber) - ->once() - ->andReturn($validationResponse); - - $this->repository - ->shouldReceive('updateFromArray') - ->twice() - ->withArgs(function ($id, $data) use ($accountVatSettingId) { - if ($id !== $accountVatSettingId) { - return false; - } - - if (isset($data['vat_validation_status']) && $data['vat_validation_status'] === VatValidationStatus::VALIDATING->value) { - return true; - } - - if (isset($data['vat_validated']) && $data['vat_validated'] === false) { - return $data['vat_validation_status'] === VatValidationStatus::INVALID->value; - } - - return false; - }) - ->andReturn($domainObject); - - $job->handle($this->viesService, $this->repository, $this->logger); - - $this->assertTrue(true); - } - - public function testJobHasCorrectRetryConfiguration(): void - { - $job = new ValidateVatNumberJob(1, 'IE1234567A'); - - $this->assertEquals(15, $job->tries); - $this->assertEquals(15, $job->maxExceptions); - $this->assertEquals(15, $job->timeout); - } - - public function testJobBackoffConfiguration(): void - { - $job = new ValidateVatNumberJob(1, 'IE1234567A'); - - $backoffs = $job->backoff(); - - $this->assertCount(15, $backoffs); - $this->assertEquals(10, $backoffs[0]); - $this->assertEquals(1800, $backoffs[14]); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } -} diff --git a/backend/tests/Unit/Listeners/Occurrence/RefundOccurrenceOrdersListenerTest.php b/backend/tests/Unit/Listeners/Occurrence/RefundOccurrenceOrdersListenerTest.php new file mode 100644 index 0000000000..fedee293b6 --- /dev/null +++ b/backend/tests/Unit/Listeners/Occurrence/RefundOccurrenceOrdersListenerTest.php @@ -0,0 +1,41 @@ +handle(new OccurrenceCancelledEvent( + eventId: 5, + occurrenceId: 42, + refundOrders: true, + )); + + Bus::assertDispatched( + RefundOccurrenceOrdersJob::class, + fn (RefundOccurrenceOrdersJob $job) => $job->eventId === 5 && $job->occurrenceId === 42, + ); + } + + public function test_does_not_dispatch_when_refund_orders_false(): void + { + Bus::fake(); + + (new RefundOccurrenceOrdersListener)->handle(new OccurrenceCancelledEvent( + eventId: 5, + occurrenceId: 42, + refundOrders: false, + )); + + Bus::assertNotDispatched(RefundOccurrenceOrdersJob::class); + } +} diff --git a/backend/tests/Unit/Listeners/Waitlist/CancelWaitlistEntriesOnOccurrenceCancelledListenerTest.php b/backend/tests/Unit/Listeners/Waitlist/CancelWaitlistEntriesOnOccurrenceCancelledListenerTest.php new file mode 100644 index 0000000000..2b6789e4d8 --- /dev/null +++ b/backend/tests/Unit/Listeners/Waitlist/CancelWaitlistEntriesOnOccurrenceCancelledListenerTest.php @@ -0,0 +1,122 @@ +waitlistEntryRepository = Mockery::mock(WaitlistEntryRepositoryInterface::class); + $this->listener = new CancelWaitlistEntriesOnOccurrenceCancelledListener( + $this->waitlistEntryRepository, + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function test_cancels_waiting_and_offered_entries_for_occurrence(): void + { + // The listener bulk-updates by event + occurrence + status. Without + // this, capacity released by attendee cancellation would let those + // entries pass the per-entry capacity check and fire offer emails for + // an occurrence that no longer exists. + $eventId = 1; + $occurrenceId = 10; + + $this->waitlistEntryRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + Mockery::on(static function (array $attrs): bool { + // Must mark CANCELLED *and* stamp cancelled_at. Without the + // timestamp, bulk-cancelled entries are indistinguishable + // from the historical-data cohort and break audit queries. + return ($attrs['status'] ?? null) === WaitlistEntryStatus::CANCELLED->name + && array_key_exists('cancelled_at', $attrs); + }), + Mockery::on(static function (array $where) use ($eventId, $occurrenceId): bool { + // event_id, event_occurrence_id, status-in-clause must all be present. + if (($where['event_id'] ?? null) !== $eventId) { + return false; + } + if (($where['event_occurrence_id'] ?? null) !== $occurrenceId) { + return false; + } + foreach ($where as $clause) { + if (is_array($clause) && $clause[0] === 'status' && $clause[1] === 'in') { + return $clause[2] === [ + WaitlistEntryStatus::WAITING->name, + WaitlistEntryStatus::OFFERED->name, + ]; + } + } + + return false; + }), + ); + + $this->listener->handle(new OccurrenceCancelledEvent( + eventId: $eventId, + occurrenceId: $occurrenceId, + refundOrders: false, + )); + + $this->assertTrue(true); + } + + public function test_only_targets_waiting_and_offered_status(): void + { + // PURCHASED, OFFER_EXPIRED, and already-CANCELLED entries should not + // be touched — flipping a PURCHASED entry to CANCELLED would lose + // sale-attribution data. + $captured = null; + + $this->waitlistEntryRepository + ->shouldReceive('updateWhere') + ->once() + ->andReturnUsing(function (array $attrs, array $where) use (&$captured): int { + $captured = $where; + + return 0; + }); + + $this->listener->handle(new OccurrenceCancelledEvent( + eventId: 1, + occurrenceId: 10, + refundOrders: true, + )); + + $statusClause = null; + foreach ($captured as $clause) { + if (is_array($clause) && $clause[0] === 'status') { + $statusClause = $clause; + break; + } + } + + $this->assertNotNull($statusClause); + $this->assertSame('in', $statusClause[1]); + $this->assertEqualsCanonicalizing( + [WaitlistEntryStatus::WAITING->name, WaitlistEntryStatus::OFFERED->name], + $statusClause[2], + ); + } +} diff --git a/backend/tests/Unit/Resources/Event/EventResourcePublicTest.php b/backend/tests/Unit/Resources/Event/EventResourcePublicTest.php new file mode 100644 index 0000000000..b7d48239f2 --- /dev/null +++ b/backend/tests/Unit/Resources/Event/EventResourcePublicTest.php @@ -0,0 +1,79 @@ +map(fn (int $id) => $this->makeOccurrence($id)) + ->push($this->makeOccurrence(250)); + + $event = (new EventDomainObject) + ->setId(1) + ->setAccountId(1) + ->setUserId(1) + ->setTitle('Recurring show') + ->setShortId('event') + ->setType(EventType::RECURRING->name) + ->setCurrency('USD') + ->setTimezone('UTC') + ->setCreatedAt('2026-01-01 00:00:00') + ->setUpdatedAt('2026-01-01 00:00:00') + ->setEventOccurrences($occurrences); + + $payload = (new EventResourcePublic($event))->toArray(new Request); + $resolvedOccurrences = $payload['occurrences']->resolve(new Request); + + $this->assertCount(201, $resolvedOccurrences); + $this->assertTrue( + collect($resolvedOccurrences)->contains(fn (array $occurrence) => $occurrence['id'] === 250) + ); + } + + public function test_public_resource_keeps_past_hidden_occurrence_for_single_event(): void + { + $pastOccurrence = $this->makeOccurrence(10) + ->setStartDate('2026-01-01 10:00:00') + ->setEndDate('2026-01-01 11:00:00'); + + $event = (new EventDomainObject) + ->setId(1) + ->setAccountId(1) + ->setUserId(1) + ->setTitle('Single show') + ->setShortId('event') + ->setType(EventType::SINGLE->name) + ->setCurrency('USD') + ->setTimezone('UTC') + ->setCreatedAt('2026-01-01 00:00:00') + ->setUpdatedAt('2026-01-01 00:00:00') + ->setEventOccurrences(collect([$pastOccurrence])); + + $payload = (new EventResourcePublic($event))->toArray(new Request); + $resolvedOccurrences = $payload['occurrences']->resolve(new Request); + + $this->assertCount(1, $resolvedOccurrences); + $this->assertSame(10, $resolvedOccurrences[0]['id']); + } + + private function makeOccurrence(int $id): EventOccurrenceDomainObject + { + return (new EventOccurrenceDomainObject) + ->setId($id) + ->setEventId(1) + ->setShortId((string) $id) + ->setStartDate('2027-01-01 10:00:00') + ->setEndDate('2027-01-01 11:00:00') + ->setStatus(EventOccurrenceStatus::ACTIVE->name); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandlerTest.php deleted file mode 100644 index 1ac7ca3c33..0000000000 --- a/backend/tests/Unit/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandlerTest.php +++ /dev/null @@ -1,90 +0,0 @@ -config = m::mock(Repository::class); - $stripeClientFactory = m::mock(StripeClientFactory::class); - $stripeConfigurationService = m::mock(StripeConfigurationService::class); - $stripeAccountSyncService = m::mock(StripeAccountSyncService::class); - - $this->handler = new CreateStripeConnectAccountHandler( - $accountRepository, - $accountStripePlatformRepository, - $databaseManager, - $logger, - $this->config, - $stripeClientFactory, - $stripeConfigurationService, - $stripeAccountSyncService, - ); - } - - public function testHandleThrowsExceptionWhenSaasModeDisabled(): void - { - $dto = new CreateStripeConnectAccountDTO(accountId: 1); - - $this->config - ->shouldReceive('get') - ->with('app.saas_mode_enabled') - ->andReturn(false); - - $this->expectException(SaasModeEnabledException::class); - $this->expectExceptionMessage('Stripe Connect Account creation is only available in Saas Mode.'); - - $this->handler->handle($dto); - } - - public function testHandleAllowsExecutionWhenSaasModeEnabled(): void - { - $dto = new CreateStripeConnectAccountDTO(accountId: 1); - - $this->config - ->shouldReceive('get') - ->with('app.saas_mode_enabled') - ->andReturn(true); - - // We expect this to NOT throw the SaasModeEnabledException - // It will fail later due to missing mocks, but that proves SaaS mode check passed - try { - $this->handler->handle($dto); - } catch (SaasModeEnabledException $e) { - $this->fail('Should not throw SaasModeEnabledException when saas mode is enabled'); - } catch (\Exception $e) { - // Expected - will fail on missing mocks, but SaaS check passed - $this->assertTrue(true); - } - } - - protected function tearDown(): void - { - m::close(); - parent::tearDown(); - } -} \ No newline at end of file diff --git a/backend/tests/Unit/Services/Application/Handlers/Account/Payment/Stripe/GetStripeConnectAccountsHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Account/Payment/Stripe/GetStripeConnectAccountsHandlerTest.php deleted file mode 100644 index aa2f48f223..0000000000 --- a/backend/tests/Unit/Services/Application/Handlers/Account/Payment/Stripe/GetStripeConnectAccountsHandlerTest.php +++ /dev/null @@ -1,148 +0,0 @@ -accountRepository = m::mock(AccountRepositoryInterface::class); - $stripeClientFactory = m::mock(StripeClientFactory::class); - $stripeAccountSyncService = m::mock(StripeAccountSyncService::class); - $logger = m::mock(LoggerInterface::class); - - $this->handler = new GetStripeConnectAccountsHandler( - $this->accountRepository, - $stripeClientFactory, - $stripeAccountSyncService, - $logger, - ); - } - - public function testHandleReturnsEmptyCollectionWhenNoStripePlatforms(): void - { - $accountId = 1; - $account = m::mock(AccountDomainObject::class); - - $this->accountRepository - ->shouldReceive('loadRelation') - ->with(AccountStripePlatformDomainObject::class) - ->andReturnSelf(); - - $this->accountRepository - ->shouldReceive('findById') - ->with($accountId) - ->andReturn($account); - - $account - ->shouldReceive('getAccountStripePlatforms') - ->andReturn(null); - - $account - ->shouldReceive('getActiveStripeAccountId') - ->andReturn(null); - - $account - ->shouldReceive('isStripeSetupComplete') - ->andReturn(false); - - $result = $this->handler->handle($accountId); - - $this->assertSame($account, $result->account); - $this->assertTrue($result->stripeConnectAccounts->isEmpty()); - $this->assertNull($result->primaryStripeAccountId); - $this->assertFalse($result->hasCompletedSetup); - } - - public function testHandleReturnsEmptyCollectionWhenStripePlatformsEmpty(): void - { - $accountId = 1; - $account = m::mock(AccountDomainObject::class); - $emptyCollection = collect([]); - - $this->accountRepository - ->shouldReceive('loadRelation') - ->with(AccountStripePlatformDomainObject::class) - ->andReturnSelf(); - - $this->accountRepository - ->shouldReceive('findById') - ->with($accountId) - ->andReturn($account); - - $account - ->shouldReceive('getAccountStripePlatforms') - ->andReturn($emptyCollection); - - $account - ->shouldReceive('getActiveStripeAccountId') - ->andReturn(null); - - $account - ->shouldReceive('isStripeSetupComplete') - ->andReturn(false); - - $result = $this->handler->handle($accountId); - - $this->assertTrue($result->stripeConnectAccounts->isEmpty()); - } - - public function testHandleSkipsAccountWithoutStripeAccountId(): void - { - $accountId = 1; - $account = m::mock(AccountDomainObject::class); - $stripePlatform = m::mock(AccountStripePlatformDomainObject::class); - $stripePlatforms = collect([$stripePlatform]); - - $this->accountRepository - ->shouldReceive('loadRelation') - ->with(AccountStripePlatformDomainObject::class) - ->andReturnSelf(); - - $this->accountRepository - ->shouldReceive('findById') - ->with($accountId) - ->andReturn($account); - - $account - ->shouldReceive('getAccountStripePlatforms') - ->andReturn($stripePlatforms); - - $stripePlatform - ->shouldReceive('getStripeAccountId') - ->andReturn(null); - - $account - ->shouldReceive('getActiveStripeAccountId') - ->andReturn(null); - - $account - ->shouldReceive('isStripeSetupComplete') - ->andReturn(false); - - $result = $this->handler->handle($accountId); - - $this->assertTrue($result->stripeConnectAccounts->isEmpty()); - } - - protected function tearDown(): void - { - m::close(); - parent::tearDown(); - } -} diff --git a/backend/tests/Unit/Services/Application/Handlers/Account/Vat/GetAccountVatSettingHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Account/Vat/GetAccountVatSettingHandlerTest.php deleted file mode 100644 index 5eddde1a39..0000000000 --- a/backend/tests/Unit/Services/Application/Handlers/Account/Vat/GetAccountVatSettingHandlerTest.php +++ /dev/null @@ -1,59 +0,0 @@ -repository = Mockery::mock(AccountVatSettingRepositoryInterface::class); - $this->handler = new GetAccountVatSettingHandler($this->repository); - } - - public function testHandleReturnsVatSetting(): void - { - $accountId = 123; - $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class); - - $this->repository - ->shouldReceive('findByAccountId') - ->with($accountId) - ->once() - ->andReturn($vatSetting); - - $result = $this->handler->handle($accountId); - - $this->assertSame($vatSetting, $result); - } - - public function testHandleReturnsNullWhenNotFound(): void - { - $accountId = 456; - - $this->repository - ->shouldReceive('findByAccountId') - ->with($accountId) - ->once() - ->andReturn(null); - - $result = $this->handler->handle($accountId); - - $this->assertNull($result); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } -} diff --git a/backend/tests/Unit/Services/Application/Handlers/Account/Vat/UpsertAccountVatSettingHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Account/Vat/UpsertAccountVatSettingHandlerTest.php deleted file mode 100644 index 30f6932a92..0000000000 --- a/backend/tests/Unit/Services/Application/Handlers/Account/Vat/UpsertAccountVatSettingHandlerTest.php +++ /dev/null @@ -1,325 +0,0 @@ -repository = Mockery::mock(AccountVatSettingRepositoryInterface::class); - $this->viesService = Mockery::mock(ViesValidationService::class); - $this->logger = Mockery::mock(LoggerInterface::class); - $this->logger->shouldReceive('info')->byDefault(); - $this->handler = new UpsertAccountVatSettingHandler( - $this->repository, - $this->viesService, - $this->logger - ); - } - - public function testHandleCreatesVatSettingWithSyncValidationSuccess(): void - { - $accountId = 123; - $vatNumber = 'IE1234567A'; - $dto = new UpsertAccountVatSettingDTO( - accountId: $accountId, - vatRegistered: true, - vatNumber: $vatNumber, - ); - - $validationResponse = new ViesValidationResponseDTO( - valid: true, - businessName: 'Test Company Ltd', - businessAddress: '123 Test Street', - countryCode: 'IE', - vatNumber: '1234567A', - isTransientError: false, - ); - - $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class); - $vatSetting->shouldReceive('getId')->andReturn(1); - - $this->repository - ->shouldReceive('findByAccountId') - ->with($accountId) - ->once() - ->andReturn(null); - - $this->viesService - ->shouldReceive('validateVatNumber') - ->with($vatNumber) - ->once() - ->andReturn($validationResponse); - - $this->repository - ->shouldReceive('create') - ->once() - ->withArgs(function ($data) use ($accountId, $vatNumber) { - return $data['account_id'] === $accountId - && $data['vat_registered'] === true - && $data['vat_number'] === $vatNumber - && $data['vat_validated'] === true - && $data['vat_validation_status'] === VatValidationStatus::VALID->value - && $data['business_name'] === 'Test Company Ltd' - && $data['vat_country_code'] === 'IE'; - }) - ->andReturn($vatSetting); - - $result = $this->handler->handle($dto); - - $this->assertSame($vatSetting, $result); - Queue::assertNotPushed(ValidateVatNumberJob::class); - } - - public function testHandleQueuesJobOnTransientError(): void - { - $accountId = 123; - $vatNumber = 'IE1234567A'; - $dto = new UpsertAccountVatSettingDTO( - accountId: $accountId, - vatRegistered: true, - vatNumber: $vatNumber, - ); - - $validationResponse = new ViesValidationResponseDTO( - valid: false, - countryCode: 'IE', - vatNumber: '1234567A', - isTransientError: true, - errorMessage: 'VIES service is temporarily busy', - ); - - $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class); - $vatSetting->shouldReceive('getId')->andReturn(1); - - $this->repository - ->shouldReceive('findByAccountId') - ->with($accountId) - ->once() - ->andReturn(null); - - $this->viesService - ->shouldReceive('validateVatNumber') - ->with($vatNumber) - ->once() - ->andReturn($validationResponse); - - $this->repository - ->shouldReceive('create') - ->once() - ->withArgs(function ($data) use ($accountId, $vatNumber) { - return $data['account_id'] === $accountId - && $data['vat_registered'] === true - && $data['vat_number'] === $vatNumber - && $data['vat_validated'] === false - && $data['vat_validation_status'] === VatValidationStatus::PENDING->value - && $data['vat_country_code'] === 'IE'; - }) - ->andReturn($vatSetting); - - $result = $this->handler->handle($dto); - - $this->assertSame($vatSetting, $result); - Queue::assertPushed(ValidateVatNumberJob::class); - } - - public function testHandleDoesNotQueueJobOnInvalidVatNumber(): void - { - $accountId = 123; - $vatNumber = 'IE9999999ZZ'; - $dto = new UpsertAccountVatSettingDTO( - accountId: $accountId, - vatRegistered: true, - vatNumber: $vatNumber, - ); - - $validationResponse = new ViesValidationResponseDTO( - valid: false, - countryCode: 'IE', - vatNumber: '9999999ZZ', - isTransientError: false, - errorMessage: 'VAT number not found', - ); - - $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class); - $vatSetting->shouldReceive('getId')->andReturn(1); - - $this->repository - ->shouldReceive('findByAccountId') - ->with($accountId) - ->once() - ->andReturn(null); - - $this->viesService - ->shouldReceive('validateVatNumber') - ->with($vatNumber) - ->once() - ->andReturn($validationResponse); - - $this->repository - ->shouldReceive('create') - ->once() - ->withArgs(function ($data) use ($accountId, $vatNumber) { - return $data['account_id'] === $accountId - && $data['vat_registered'] === true - && $data['vat_number'] === $vatNumber - && $data['vat_validated'] === false - && $data['vat_validation_status'] === VatValidationStatus::INVALID->value; - }) - ->andReturn($vatSetting); - - $result = $this->handler->handle($dto); - - $this->assertSame($vatSetting, $result); - Queue::assertNotPushed(ValidateVatNumberJob::class); - } - - public function testHandleCreatesVatSettingWithInvalidFormat(): void - { - $accountId = 123; - $vatNumber = 'INVALID'; - $dto = new UpsertAccountVatSettingDTO( - accountId: $accountId, - vatRegistered: true, - vatNumber: $vatNumber, - ); - - $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class); - $vatSetting->shouldReceive('getId')->andReturn(1); - - $this->repository - ->shouldReceive('findByAccountId') - ->with($accountId) - ->once() - ->andReturn(null); - - $this->viesService - ->shouldNotReceive('validateVatNumber'); - - $this->repository - ->shouldReceive('create') - ->once() - ->withArgs(function ($data) use ($accountId) { - return $data['account_id'] === $accountId - && $data['vat_registered'] === true - && $data['vat_number'] === 'INVALID' - && $data['vat_validated'] === false - && $data['vat_validation_status'] === VatValidationStatus::INVALID->value - && $data['business_name'] === null; - }) - ->andReturn($vatSetting); - - $result = $this->handler->handle($dto); - - $this->assertSame($vatSetting, $result); - Queue::assertNotPushed(ValidateVatNumberJob::class); - } - - public function testHandleCreatesVatSettingForNonRegistered(): void - { - $accountId = 123; - $dto = new UpsertAccountVatSettingDTO( - accountId: $accountId, - vatRegistered: false, - ); - - $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class); - $vatSetting->shouldReceive('getId')->andReturn(1); - - $this->repository - ->shouldReceive('findByAccountId') - ->with($accountId) - ->once() - ->andReturn(null); - - $this->viesService - ->shouldNotReceive('validateVatNumber'); - - $this->repository - ->shouldReceive('create') - ->once() - ->withArgs(function ($data) use ($accountId) { - return $data['account_id'] === $accountId - && $data['vat_registered'] === false - && $data['vat_number'] === null - && $data['vat_validated'] === false; - }) - ->andReturn($vatSetting); - - $result = $this->handler->handle($dto); - - $this->assertSame($vatSetting, $result); - Queue::assertNotPushed(ValidateVatNumberJob::class); - } - - public function testHandleDoesNotValidateIfVatNumberUnchanged(): void - { - $accountId = 123; - $existingId = 456; - $vatNumber = 'DE123456789'; - - $existing = Mockery::mock(AccountVatSettingDomainObject::class); - $existing->shouldReceive('getId')->andReturn($existingId); - $existing->shouldReceive('getVatNumber')->andReturn($vatNumber); - - $dto = new UpsertAccountVatSettingDTO( - accountId: $accountId, - vatRegistered: true, - vatNumber: $vatNumber, - ); - - $updated = Mockery::mock(AccountVatSettingDomainObject::class); - $updated->shouldReceive('getId')->andReturn($existingId); - - $this->repository - ->shouldReceive('findByAccountId') - ->with($accountId) - ->once() - ->andReturn($existing); - - $this->viesService - ->shouldNotReceive('validateVatNumber'); - - $this->repository - ->shouldReceive('updateFromArray') - ->once() - ->with($existingId, Mockery::on(function ($data) use ($accountId, $vatNumber) { - return $data['account_id'] === $accountId - && $data['vat_registered'] === true - && $data['vat_number'] === $vatNumber - && !isset($data['vat_validated']); - })) - ->andReturn($updated); - - $result = $this->handler->handle($dto); - - $this->assertSame($updated, $result); - Queue::assertNotPushed(ValidateVatNumberJob::class); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } -} diff --git a/backend/tests/Unit/Services/Application/Handlers/Admin/AssignConfigurationHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Admin/AssignConfigurationHandlerTest.php deleted file mode 100644 index 4752bf23c8..0000000000 --- a/backend/tests/Unit/Services/Application/Handlers/Admin/AssignConfigurationHandlerTest.php +++ /dev/null @@ -1,79 +0,0 @@ -accountRepository = Mockery::mock(AccountRepositoryInterface::class); - $this->configurationRepository = Mockery::mock(AccountConfigurationRepositoryInterface::class); - $this->handler = new AssignConfigurationHandler( - $this->accountRepository, - $this->configurationRepository - ); - } - - public function testHandleSuccessfullyAssignsConfiguration(): void - { - $accountId = 123; - $configurationId = 456; - $configuration = Mockery::mock(AccountConfigurationDomainObject::class); - $account = Mockery::mock(AccountDomainObject::class); - - $this->configurationRepository - ->shouldReceive('findById') - ->with($configurationId) - ->once() - ->andReturn($configuration); - - $this->accountRepository - ->shouldReceive('updateFromArray') - ->with($accountId, ['account_configuration_id' => $configurationId]) - ->once() - ->andReturn($account); - - $this->handler->handle($accountId, $configurationId); - - $this->assertTrue(true); - } - - public function testHandleThrowsExceptionWhenConfigurationNotFound(): void - { - $accountId = 123; - $configurationId = 999; - - $this->configurationRepository - ->shouldReceive('findById') - ->with($configurationId) - ->once() - ->andThrow(new ModelNotFoundException()); - - $this->accountRepository - ->shouldNotReceive('updateFromArray'); - - $this->expectException(ModelNotFoundException::class); - - $this->handler->handle($accountId, $configurationId); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } -} diff --git a/backend/tests/Unit/Services/Application/Handlers/Admin/DeleteConfigurationHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Admin/DeleteConfigurationHandlerTest.php deleted file mode 100644 index 8844fb8c13..0000000000 --- a/backend/tests/Unit/Services/Application/Handlers/Admin/DeleteConfigurationHandlerTest.php +++ /dev/null @@ -1,95 +0,0 @@ -repository = Mockery::mock(AccountConfigurationRepositoryInterface::class); - $this->handler = new DeleteConfigurationHandler($this->repository); - } - - public function testHandleSuccessfullyDeletesConfiguration(): void - { - $configurationId = 123; - $configuration = Mockery::mock(AccountConfigurationDomainObject::class); - - $configuration - ->shouldReceive('getIsSystemDefault') - ->once() - ->andReturn(false); - - $this->repository - ->shouldReceive('findById') - ->with($configurationId) - ->once() - ->andReturn($configuration); - - $this->repository - ->shouldReceive('deleteById') - ->with($configurationId) - ->once(); - - $this->handler->handle($configurationId); - - $this->assertTrue(true); - } - - public function testHandleThrowsExceptionWhenDeletingSystemDefault(): void - { - $configurationId = 1; - $configuration = Mockery::mock(AccountConfigurationDomainObject::class); - - $configuration - ->shouldReceive('getIsSystemDefault') - ->once() - ->andReturn(true); - - $this->repository - ->shouldReceive('findById') - ->with($configurationId) - ->once() - ->andReturn($configuration); - - $this->repository - ->shouldNotReceive('deleteById'); - - $this->expectException(CannotDeleteEntityException::class); - $this->expectExceptionMessage('The system default configuration cannot be deleted.'); - - $this->handler->handle($configurationId); - } - - public function testHandleThrowsExceptionWhenConfigurationNotFound(): void - { - $configurationId = 999; - - $this->repository - ->shouldReceive('findById') - ->with($configurationId) - ->once() - ->andThrow(new \Illuminate\Database\Eloquent\ModelNotFoundException()); - - $this->expectException(\Illuminate\Database\Eloquent\ModelNotFoundException::class); - - $this->handler->handle($configurationId); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } -} diff --git a/backend/tests/Unit/Services/Application/Handlers/Admin/GetAccountHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Admin/GetAccountHandlerTest.php deleted file mode 100644 index 343d34549d..0000000000 --- a/backend/tests/Unit/Services/Application/Handlers/Admin/GetAccountHandlerTest.php +++ /dev/null @@ -1,59 +0,0 @@ -repository = Mockery::mock(AccountRepositoryInterface::class); - $this->handler = new GetAccountHandler($this->repository); - } - - public function testHandleReturnsAccountWithDetails(): void - { - $accountId = 123; - $account = Mockery::mock(Account::class); - - $this->repository - ->shouldReceive('getAccountWithDetails') - ->with($accountId) - ->once() - ->andReturn($account); - - $result = $this->handler->handle($accountId); - - $this->assertSame($account, $result); - } - - public function testHandleThrowsExceptionWhenAccountNotFound(): void - { - $accountId = 999; - - $this->repository - ->shouldReceive('getAccountWithDetails') - ->with($accountId) - ->once() - ->andThrow(new \Illuminate\Database\Eloquent\ModelNotFoundException()); - - $this->expectException(\Illuminate\Database\Eloquent\ModelNotFoundException::class); - - $this->handler->handle($accountId); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } -} diff --git a/backend/tests/Unit/Services/Application/Handlers/Admin/GetAllOrdersHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Admin/GetAllOrdersHandlerTest.php deleted file mode 100644 index 52010d725f..0000000000 --- a/backend/tests/Unit/Services/Application/Handlers/Admin/GetAllOrdersHandlerTest.php +++ /dev/null @@ -1,134 +0,0 @@ -repository = Mockery::mock(OrderRepositoryInterface::class); - $this->handler = new GetAllOrdersHandler($this->repository); - } - - public function testHandleReturnsPaginatedOrders(): void - { - $dto = new GetAllOrdersDTO( - perPage: 20, - search: null, - sortBy: 'created_at', - sortDirection: 'desc', - ); - - $paginator = Mockery::mock(LengthAwarePaginator::class); - - $this->repository - ->shouldReceive('getAllOrdersForAdmin') - ->with(null, 20, 'created_at', 'desc') - ->once() - ->andReturn($paginator); - - $result = $this->handler->handle($dto); - - $this->assertSame($paginator, $result); - } - - public function testHandleWithSearchQuery(): void - { - $dto = new GetAllOrdersDTO( - perPage: 10, - search: 'test@example.com', - sortBy: 'created_at', - sortDirection: 'desc', - ); - - $paginator = Mockery::mock(LengthAwarePaginator::class); - - $this->repository - ->shouldReceive('getAllOrdersForAdmin') - ->with('test@example.com', 10, 'created_at', 'desc') - ->once() - ->andReturn($paginator); - - $result = $this->handler->handle($dto); - - $this->assertSame($paginator, $result); - } - - public function testHandleWithCustomSorting(): void - { - $dto = new GetAllOrdersDTO( - perPage: 25, - search: null, - sortBy: 'total_gross', - sortDirection: 'asc', - ); - - $paginator = Mockery::mock(LengthAwarePaginator::class); - - $this->repository - ->shouldReceive('getAllOrdersForAdmin') - ->with(null, 25, 'total_gross', 'asc') - ->once() - ->andReturn($paginator); - - $result = $this->handler->handle($dto); - - $this->assertSame($paginator, $result); - } - - public function testHandleWithDefaultValues(): void - { - $dto = new GetAllOrdersDTO(); - - $paginator = Mockery::mock(LengthAwarePaginator::class); - - $this->repository - ->shouldReceive('getAllOrdersForAdmin') - ->with(null, 20, 'created_at', 'desc') - ->once() - ->andReturn($paginator); - - $result = $this->handler->handle($dto); - - $this->assertSame($paginator, $result); - } - - public function testHandleWithNameSearch(): void - { - $dto = new GetAllOrdersDTO( - perPage: 20, - search: 'John Doe', - sortBy: 'first_name', - sortDirection: 'asc', - ); - - $paginator = Mockery::mock(LengthAwarePaginator::class); - - $this->repository - ->shouldReceive('getAllOrdersForAdmin') - ->with('John Doe', 20, 'first_name', 'asc') - ->once() - ->andReturn($paginator); - - $result = $this->handler->handle($dto); - - $this->assertSame($paginator, $result); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } -} diff --git a/backend/tests/Unit/Services/Application/Handlers/Admin/UpdateAccountConfigurationHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Admin/UpdateAccountConfigurationHandlerTest.php deleted file mode 100644 index 2f594daaa7..0000000000 --- a/backend/tests/Unit/Services/Application/Handlers/Admin/UpdateAccountConfigurationHandlerTest.php +++ /dev/null @@ -1,170 +0,0 @@ -configurationRepository = Mockery::mock(AccountConfigurationRepositoryInterface::class); - $this->accountRepository = Mockery::mock(AccountRepositoryInterface::class); - $this->handler = new UpdateAccountConfigurationHandler( - $this->configurationRepository, - $this->accountRepository - ); - } - - public function testHandleUpdatesExistingConfiguration(): void - { - $accountId = 123; - $configurationId = 456; - $applicationFees = ['fixed' => 100, 'percentage' => 2.5]; - - $existingConfig = Mockery::mock(AccountConfigurationDomainObject::class); - $existingConfig->shouldReceive('getId')->andReturn($configurationId); - - $account = Mockery::mock(AccountDomainObject::class); - $account->shouldReceive('getConfiguration')->andReturn($existingConfig); - - $updatedConfig = Mockery::mock(AccountConfigurationDomainObject::class); - - $dto = new UpdateAccountConfigurationDTO( - accountId: $accountId, - applicationFees: $applicationFees, - ); - - $this->accountRepository - ->shouldReceive('loadRelation') - ->with('configuration') - ->once() - ->andReturnSelf(); - - $this->accountRepository - ->shouldReceive('findById') - ->with($accountId) - ->once() - ->andReturn($account); - - $this->configurationRepository - ->shouldReceive('updateFromArray') - ->with($configurationId, ['application_fees' => $applicationFees]) - ->once() - ->andReturn($updatedConfig); - - $result = $this->handler->handle($dto); - - $this->assertSame($updatedConfig, $result); - } - - public function testHandleCreatesNewConfigurationWhenNoneExists(): void - { - $accountId = 123; - $applicationFees = ['fixed' => 50, 'percentage' => 1.5]; - - $account = Mockery::mock(AccountDomainObject::class); - $account->shouldReceive('getConfiguration')->andReturn(null); - $account->shouldReceive('getId')->andReturn($accountId); - - $newConfig = Mockery::mock(AccountConfigurationDomainObject::class); - $newConfig->shouldReceive('getId')->andReturn(789); - - $dto = new UpdateAccountConfigurationDTO( - accountId: $accountId, - applicationFees: $applicationFees, - ); - - $this->accountRepository - ->shouldReceive('loadRelation') - ->with('configuration') - ->once() - ->andReturnSelf(); - - $this->accountRepository - ->shouldReceive('findById') - ->with($accountId) - ->once() - ->andReturn($account); - - $this->configurationRepository - ->shouldReceive('create') - ->with([ - 'name' => 'Account Configuration', - 'is_system_default' => false, - 'application_fees' => $applicationFees, - ]) - ->once() - ->andReturn($newConfig); - - $this->accountRepository - ->shouldReceive('updateFromArray') - ->with($accountId, ['account_configuration_id' => 789]) - ->once() - ->andReturn($account); - - $result = $this->handler->handle($dto); - - $this->assertSame($newConfig, $result); - } - - public function testHandleWithZeroFees(): void - { - $accountId = 123; - $configurationId = 456; - $applicationFees = ['fixed' => 0, 'percentage' => 0]; - - $existingConfig = Mockery::mock(AccountConfigurationDomainObject::class); - $existingConfig->shouldReceive('getId')->andReturn($configurationId); - - $account = Mockery::mock(AccountDomainObject::class); - $account->shouldReceive('getConfiguration')->andReturn($existingConfig); - - $updatedConfig = Mockery::mock(AccountConfigurationDomainObject::class); - - $dto = new UpdateAccountConfigurationDTO( - accountId: $accountId, - applicationFees: $applicationFees, - ); - - $this->accountRepository - ->shouldReceive('loadRelation') - ->with('configuration') - ->once() - ->andReturnSelf(); - - $this->accountRepository - ->shouldReceive('findById') - ->with($accountId) - ->once() - ->andReturn($account); - - $this->configurationRepository - ->shouldReceive('updateFromArray') - ->with($configurationId, ['application_fees' => $applicationFees]) - ->once() - ->andReturn($updatedConfig); - - $result = $this->handler->handle($dto); - - $this->assertSame($updatedConfig, $result); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } -} diff --git a/backend/tests/Unit/Services/Application/Handlers/Admin/UpdateAdminAccountVatSettingHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Admin/UpdateAdminAccountVatSettingHandlerTest.php deleted file mode 100644 index 5a8e11b0b9..0000000000 --- a/backend/tests/Unit/Services/Application/Handlers/Admin/UpdateAdminAccountVatSettingHandlerTest.php +++ /dev/null @@ -1,185 +0,0 @@ -repository = Mockery::mock(AccountVatSettingRepositoryInterface::class); - $this->handler = new UpdateAdminAccountVatSettingHandler($this->repository); - } - - public function testHandleCreatesNewVatSetting(): void - { - $accountId = 123; - $dto = new UpdateAdminAccountVatSettingDTO( - accountId: $accountId, - vatRegistered: true, - vatNumber: 'DE123456789', - vatValidated: true, - businessName: 'Test Company', - businessAddress: '123 Test St', - vatCountryCode: 'DE', - ); - - $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class); - - $this->repository - ->shouldReceive('findByAccountId') - ->with($accountId) - ->once() - ->andReturn(null); - - $this->repository - ->shouldReceive('create') - ->once() - ->withArgs(function ($data) use ($accountId) { - return $data['account_id'] === $accountId - && $data['vat_registered'] === true - && $data['vat_number'] === 'DE123456789' - && $data['vat_validated'] === true - && $data['business_name'] === 'Test Company' - && $data['business_address'] === '123 Test St' - && $data['vat_country_code'] === 'DE'; - }) - ->andReturn($vatSetting); - - $result = $this->handler->handle($dto); - - $this->assertSame($vatSetting, $result); - } - - public function testHandleUpdatesExistingVatSetting(): void - { - $accountId = 123; - $existingId = 456; - - $existing = Mockery::mock(AccountVatSettingDomainObject::class); - $existing->shouldReceive('getId')->andReturn($existingId); - - $dto = new UpdateAdminAccountVatSettingDTO( - accountId: $accountId, - vatRegistered: true, - vatNumber: 'IE1234567A', - vatValidated: false, - businessName: 'Updated Company', - businessAddress: '456 New St', - vatCountryCode: 'IE', - ); - - $updated = Mockery::mock(AccountVatSettingDomainObject::class); - - $this->repository - ->shouldReceive('findByAccountId') - ->with($accountId) - ->once() - ->andReturn($existing); - - $this->repository - ->shouldReceive('updateFromArray') - ->once() - ->with($existingId, Mockery::on(function ($data) use ($accountId) { - return $data['account_id'] === $accountId - && $data['vat_registered'] === true - && $data['vat_number'] === 'IE1234567A' - && $data['vat_validated'] === false - && $data['business_name'] === 'Updated Company' - && $data['business_address'] === '456 New St' - && $data['vat_country_code'] === 'IE'; - })) - ->andReturn($updated); - - $result = $this->handler->handle($dto); - - $this->assertSame($updated, $result); - } - - public function testHandleCreatesNonRegisteredVatSetting(): void - { - $accountId = 123; - $dto = new UpdateAdminAccountVatSettingDTO( - accountId: $accountId, - vatRegistered: false, - ); - - $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class); - - $this->repository - ->shouldReceive('findByAccountId') - ->with($accountId) - ->once() - ->andReturn(null); - - $this->repository - ->shouldReceive('create') - ->once() - ->withArgs(function ($data) use ($accountId) { - return $data['account_id'] === $accountId - && $data['vat_registered'] === false - && $data['vat_number'] === null - && $data['vat_validated'] === false - && $data['business_name'] === null - && $data['business_address'] === null - && $data['vat_country_code'] === null; - }) - ->andReturn($vatSetting); - - $result = $this->handler->handle($dto); - - $this->assertSame($vatSetting, $result); - } - - public function testHandleWithPartialData(): void - { - $accountId = 123; - $dto = new UpdateAdminAccountVatSettingDTO( - accountId: $accountId, - vatRegistered: true, - vatNumber: 'FR12345678901', - ); - - $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class); - - $this->repository - ->shouldReceive('findByAccountId') - ->with($accountId) - ->once() - ->andReturn(null); - - $this->repository - ->shouldReceive('create') - ->once() - ->withArgs(function ($data) use ($accountId) { - return $data['account_id'] === $accountId - && $data['vat_registered'] === true - && $data['vat_number'] === 'FR12345678901' - && $data['vat_validated'] === false - && $data['business_name'] === null - && $data['business_address'] === null - && $data['vat_country_code'] === null; - }) - ->andReturn($vatSetting); - - $result = $this->handler->handle($dto); - - $this->assertSame($vatSetting, $result); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } -} diff --git a/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeeDetailPublicHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeeDetailPublicHandlerTest.php new file mode 100644 index 0000000000..c1f90f8a2a --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeeDetailPublicHandlerTest.php @@ -0,0 +1,174 @@ +checkInListRepository = m::mock(CheckInListRepositoryInterface::class); + $this->attendeeRepository = m::mock(AttendeeRepositoryInterface::class); + + $this->handler = new GetCheckInListAttendeeDetailPublicHandler( + $this->attendeeRepository, + $this->checkInListRepository + ); + } + + public function testHandleThrowsNotFoundIfCheckInListMissing(): void + { + $this->checkInListRepository + ->shouldReceive('loadRelation')->andReturnSelf(); + $this->checkInListRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturnNull(); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle('short-id', 'A-123', null); + } + + public function testHandleThrowsNotFoundIfAttendeeMissing(): void + { + $checkInList = $this->buildList(eventId: 5); + + $this->checkInListRepository + ->shouldReceive('loadRelation')->andReturnSelf(); + $this->checkInListRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturn($checkInList); + + $this->attendeeRepository + ->shouldReceive('loadRelation')->andReturnSelf(); + $this->attendeeRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['public_id' => 'A-123', 'event_id' => 5]) + ->andReturnNull(); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle('short-id', 'A-123', null); + } + + public function testAnonymousRequestRespectsListVisibilityFlags(): void + { + $checkInList = $this->buildList( + eventId: 5, + accountId: 77, + showNotes: false, + showQuestions: true, + showOrderDetails: false, + ); + $attendee = m::mock(AttendeeDomainObject::class); + $attendee->shouldReceive('getCheckIns')->andReturn(null); + + $this->setupRepos($checkInList, $attendee); + + $result = $this->handler->handle('short-id', 'A-123', null); + + $this->assertFalse($result->showNotes); + $this->assertTrue($result->showQuestionAnswers); + $this->assertFalse($result->showOrderDetails); + } + + public function testAuthenticatedStaffBypassesVisibilityFlags(): void + { + $checkInList = $this->buildList( + eventId: 5, + accountId: 77, + showNotes: false, + showQuestions: false, + showOrderDetails: false, + ); + $attendee = m::mock(AttendeeDomainObject::class); + $attendee->shouldReceive('getCheckIns')->andReturn(null); + + $this->setupRepos($checkInList, $attendee); + + $result = $this->handler->handle('short-id', 'A-123', staffAccountId: 77); + + $this->assertTrue($result->showNotes); + $this->assertTrue($result->showQuestionAnswers); + $this->assertTrue($result->showOrderDetails); + } + + public function testAuthenticatedUserFromDifferentAccountStillFiltered(): void + { + $checkInList = $this->buildList( + eventId: 5, + accountId: 77, + showNotes: false, + showQuestions: false, + showOrderDetails: false, + ); + $attendee = m::mock(AttendeeDomainObject::class); + $attendee->shouldReceive('getCheckIns')->andReturn(null); + + $this->setupRepos($checkInList, $attendee); + + $result = $this->handler->handle('short-id', 'A-123', staffAccountId: 88); + + $this->assertFalse($result->showNotes); + $this->assertFalse($result->showQuestionAnswers); + $this->assertFalse($result->showOrderDetails); + } + + private function buildList( + int $eventId = 5, + int $accountId = 1, + bool $showNotes = true, + bool $showQuestions = true, + bool $showOrderDetails = true, + ): CheckInListDomainObject { + $event = m::mock(EventDomainObject::class); + $event->shouldReceive('getAccountId')->andReturn($accountId); + + $checkInList = m::mock(CheckInListDomainObject::class); + $checkInList->shouldReceive('getId')->andReturn(1); + $checkInList->shouldReceive('getEventId')->andReturn($eventId); + $checkInList->shouldReceive('getEvent')->andReturn($event); + $checkInList->shouldReceive('getExpiresAt')->andReturn(null); + $checkInList->shouldReceive('getActivatesAt')->andReturn(null); + $checkInList->shouldReceive('getProducts')->andReturn(new Collection()); + $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(null); + $checkInList->shouldReceive('getPublicShowAttendeeNotes')->andReturn($showNotes); + $checkInList->shouldReceive('getPublicShowQuestionAnswers')->andReturn($showQuestions); + $checkInList->shouldReceive('getPublicShowOrderDetails')->andReturn($showOrderDetails); + return $checkInList; + } + + private function setupRepos(CheckInListDomainObject $checkInList, $attendee): void + { + $this->checkInListRepository + ->shouldReceive('loadRelation')->andReturnSelf(); + $this->checkInListRepository + ->shouldReceive('findFirstWhere') + ->andReturn($checkInList); + + $this->attendeeRepository + ->shouldReceive('loadRelation')->andReturnSelf(); + $this->attendeeRepository + ->shouldReceive('findFirstWhere') + ->andReturn($attendee); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeePublicHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeePublicHandlerTest.php index d07cf0bae8..c2b5bbea09 100644 --- a/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeePublicHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeePublicHandlerTest.php @@ -8,6 +8,7 @@ use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface; use HiEvents\Services\Application\Handlers\CheckInList\Public\GetCheckInListAttendeePublicHandler; +use Illuminate\Support\Collection; use Mockery as m; use Symfony\Component\Routing\Exception\ResourceNotFoundException; use Tests\TestCase; @@ -95,6 +96,8 @@ public function testHandleReturnsAttendeeSuccessfully(): void $checkInList->shouldReceive('getExpiresAt')->once()->andReturn(null); $checkInList->shouldReceive('getActivatesAt')->once()->andReturn(null); $checkInList->shouldReceive('getEventId')->once()->andReturn(123); + $checkInList->shouldReceive('getProducts')->once()->andReturn(new Collection()); + $checkInList->shouldReceive('getEventOccurrenceId')->once()->andReturn(null); $attendee = m::mock(AttendeeDomainObject::class); diff --git a/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandlerTest.php new file mode 100644 index 0000000000..988e1c07cb --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandlerTest.php @@ -0,0 +1,149 @@ +checkInListRepository = m::mock(CheckInListRepositoryInterface::class); + $this->attendeeRepository = m::mock(AttendeeRepositoryInterface::class); + + $this->handler = new GetCheckInListAttendeesPublicHandler( + $this->attendeeRepository, + $this->checkInListRepository, + ); + } + + private function buildCheckInList(?int $occurrenceId): CheckInListDomainObject + { + $list = m::mock(CheckInListDomainObject::class); + $list->shouldReceive('getId')->andReturn(1); + $list->shouldReceive('getEventOccurrenceId')->andReturn($occurrenceId); + $list->shouldReceive('getExpiresAt')->andReturn(null); + $list->shouldReceive('getActivatesAt')->andReturn(null); + + return $list; + } + + private function expectCheckInListLoaded(CheckInListDomainObject $list): void + { + $this->checkInListRepository + ->shouldReceive('loadRelation')->andReturnSelf(); + $this->checkInListRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturn($list); + } + + private function emptyAttendeePaginator(): Paginator + { + return new Paginator(collect([]), 10); + } + + public function testInjectsCheckInListOccurrenceFilterWhenListIsScoped(): void + { + $this->expectCheckInListLoaded($this->buildCheckInList(occurrenceId: 42)); + + $capturedParams = null; + $this->attendeeRepository + ->shouldReceive('getAttendeesByCheckInShortId') + ->once() + ->with('short-id', m::on(function (QueryParamsDTO $params) use (&$capturedParams) { + $capturedParams = $params; + return true; + })) + ->andReturn($this->emptyAttendeePaginator()); + + $this->handler->handle('short-id', new QueryParamsDTO()); + + $this->assertNotNull($capturedParams); + $filter = $capturedParams->filter_fields->firstWhere('field', 'event_occurrence_id'); + $this->assertNotNull($filter, 'expected event_occurrence_id filter to be injected'); + $this->assertSame('42', $filter->value); + } + + public function testOverridesClientSuppliedOccurrenceFilterForScopedList(): void + { + // A misbehaving or stale client might try to pass a different occurrence id. + // For a scoped list we must ignore it and force the list's own occurrence. + $this->expectCheckInListLoaded($this->buildCheckInList(occurrenceId: 42)); + + $capturedParams = null; + $this->attendeeRepository + ->shouldReceive('getAttendeesByCheckInShortId') + ->once() + ->with('short-id', m::on(function (QueryParamsDTO $params) use (&$capturedParams) { + $capturedParams = $params; + return true; + })) + ->andReturn($this->emptyAttendeePaginator()); + + $clientParams = new QueryParamsDTO(filter_fields: collect([ + new FilterFieldDTO(field: 'event_occurrence_id', operator: 'eq', value: '99'), + new FilterFieldDTO(field: 'status', operator: 'eq', value: 'ACTIVE'), + ])); + + $this->handler->handle('short-id', $clientParams); + + /** @var Collection $filters */ + $filters = $capturedParams->filter_fields; + $occurrenceFilters = $filters->where('field', 'event_occurrence_id'); + $this->assertCount(1, $occurrenceFilters, 'client filter must be replaced, not appended'); + $this->assertSame('42', $occurrenceFilters->first()->value); + + // Non-occurrence client filters are preserved. + $this->assertCount(1, $filters->where('field', 'status')); + } + + public function testLeavesClientSuppliedOccurrenceFilterAloneWhenListIsNotScoped(): void + { + // Unscoped ("All occurrences") list — respect the client's optional filter + // (this is how the filter pill in the check-in UI works). + $this->expectCheckInListLoaded($this->buildCheckInList(occurrenceId: null)); + + $capturedParams = null; + $this->attendeeRepository + ->shouldReceive('getAttendeesByCheckInShortId') + ->once() + ->with('short-id', m::on(function (QueryParamsDTO $params) use (&$capturedParams) { + $capturedParams = $params; + return true; + })) + ->andReturn($this->emptyAttendeePaginator()); + + $clientParams = new QueryParamsDTO(filter_fields: collect([ + new FilterFieldDTO(field: 'event_occurrence_id', operator: 'eq', value: '77'), + ])); + + $this->handler->handle('short-id', $clientParams); + + $filter = $capturedParams->filter_fields->firstWhere('field', 'event_occurrence_id'); + $this->assertNotNull($filter); + $this->assertSame('77', $filter->value); + } + + protected function tearDown(): void + { + m::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListStatsPublicHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListStatsPublicHandlerTest.php new file mode 100644 index 0000000000..859527e3a3 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListStatsPublicHandlerTest.php @@ -0,0 +1,173 @@ +checkInListRepository = m::mock(CheckInListRepositoryInterface::class); + + $this->handler = new GetCheckInListStatsPublicHandler( + $this->checkInListRepository + ); + } + + public function testHandleThrowsNotFoundIfCheckInListMissing(): void + { + $this->checkInListRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['short_id' => 'short-id']) + ->andReturnNull(); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle('short-id'); + } + + public function testHandleReturnsStatsDTO(): void + { + $checkInList = m::mock(CheckInListDomainObject::class); + $checkInList->shouldReceive('getId')->andReturn(42); + $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(null); + + $this->checkInListRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['short_id' => 'short-id']) + ->andReturn($checkInList); + + $this->checkInListRepository + ->shouldReceive('getCheckedInAttendeeCountById') + ->once() + ->with(42, null) + ->andReturn(new CheckedInAttendeesCountDTO( + checkInListId: 42, + checkedInCount: 5, + totalAttendeesCount: 20, + )); + + $productStats = collect([ + new CheckInListProductStatDTO( + productId: 1, + productTitle: 'VIP', + totalAttendees: 10, + checkedInAttendees: 3, + ), + new CheckInListProductStatDTO( + productId: 2, + productTitle: 'General', + totalAttendees: 10, + checkedInAttendees: 2, + ), + ]); + + $this->checkInListRepository + ->shouldReceive('getPerProductCheckInStatsById') + ->once() + ->with(42, null) + ->andReturn($productStats); + + $recentCheckIns = collect([ + new CheckInListRecentCheckInDTO( + attendeePublicId: 'A-AAAAAAAA', + firstName: 'Alice', + lastName: 'Smith', + productTitle: 'VIP', + checkedInAt: '2026-04-20T10:00:00Z', + ), + ]); + + $this->checkInListRepository + ->shouldReceive('getRecentCheckInsById') + ->once() + ->with(42, 20, null) + ->andReturn($recentCheckIns); + + $stats = $this->handler->handle('short-id'); + + $this->assertSame(20, $stats->totalAttendees); + $this->assertSame(5, $stats->checkedInAttendees); + $this->assertCount(2, $stats->perProduct); + $this->assertSame('VIP', $stats->perProduct[0]->productTitle); + $this->assertSame(3, $stats->perProduct[0]->checkedInAttendees); + $this->assertCount(1, $stats->recentCheckIns); + $this->assertSame('Alice', $stats->recentCheckIns[0]->firstName); + } + + public function testScopedListIgnoresClientOccurrenceFilter(): void + { + // A check-in list scoped to occurrence 99 must always report its own scope, + // regardless of what the client passes. Passing null to the repository lets + // it auto-scope via cil.event_occurrence_id. + $checkInList = m::mock(CheckInListDomainObject::class); + $checkInList->shouldReceive('getId')->andReturn(42); + $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(99); + + $this->checkInListRepository + ->shouldReceive('findFirstWhere')->once() + ->andReturn($checkInList); + + $this->checkInListRepository->shouldReceive('getCheckedInAttendeeCountById') + ->once()->with(42, null) + ->andReturn(new CheckedInAttendeesCountDTO(checkInListId: 42, checkedInCount: 0, totalAttendeesCount: 0)); + + $this->checkInListRepository->shouldReceive('getPerProductCheckInStatsById') + ->once()->with(42, null) + ->andReturn(collect()); + + $this->checkInListRepository->shouldReceive('getRecentCheckInsById') + ->once()->with(42, 20, null) + ->andReturn(collect()); + + // Client tries to override with occurrence 77; handler should ignore it. + $this->handler->handle('short-id', 77); + + $this->assertTrue(true); + } + + public function testUnscopedListRespectsClientOccurrenceFilter(): void + { + // Unscoped list ("All occurrences") — the client's filter-pill selection + // propagates through to the repository so stats reflect the filtered view. + $checkInList = m::mock(CheckInListDomainObject::class); + $checkInList->shouldReceive('getId')->andReturn(42); + $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(null); + + $this->checkInListRepository + ->shouldReceive('findFirstWhere')->once() + ->andReturn($checkInList); + + $this->checkInListRepository->shouldReceive('getCheckedInAttendeeCountById') + ->once()->with(42, 77) + ->andReturn(new CheckedInAttendeesCountDTO(checkInListId: 42, checkedInCount: 0, totalAttendeesCount: 0)); + + $this->checkInListRepository->shouldReceive('getPerProductCheckInStatsById') + ->once()->with(42, 77) + ->andReturn(collect()); + + $this->checkInListRepository->shouldReceive('getRecentCheckInsById') + ->once()->with(42, 20, 77) + ->andReturn(collect()); + + $this->handler->handle('short-id', 77); + + $this->assertTrue(true); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php index 9dffed4623..3cb83cc2dd 100644 --- a/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php @@ -2,23 +2,35 @@ namespace Tests\Unit\Services\Application\Handlers\Event; +use HiEvents\DomainObjects\Enums\EventType; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; +use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract; use HiEvents\DomainObjects\PromoCodeDomainObject; +use HiEvents\DomainObjects\Status\EventOccurrenceStatus; +use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; use HiEvents\Services\Application\Handlers\Event\DTO\GetPublicEventDTO; use HiEvents\Services\Application\Handlers\Event\GetPublicEventHandler; use HiEvents\Services\Domain\Event\EventPageViewIncrementService; use HiEvents\Services\Domain\Product\ProductFilterService; +use Illuminate\Support\Collection; use Mockery as m; use Tests\TestCase; class GetPublicEventHandlerTest extends TestCase { private EventRepositoryInterface $eventRepository; + + private EventOccurrenceRepositoryInterface $occurrenceRepository; + private PromoCodeRepositoryInterface $promoCodeRepository; + private ProductFilterService $ticketFilterService; + private EventPageViewIncrementService $eventPageViewIncrementService; + private GetPublicEventHandler $handler; protected function setUp(): void @@ -26,22 +38,24 @@ protected function setUp(): void parent::setUp(); $this->eventRepository = m::mock(EventRepositoryInterface::class); + $this->occurrenceRepository = m::mock(EventOccurrenceRepositoryInterface::class); $this->promoCodeRepository = m::mock(PromoCodeRepositoryInterface::class); $this->ticketFilterService = m::mock(ProductFilterService::class); $this->eventPageViewIncrementService = m::mock(EventPageViewIncrementService::class); $this->handler = new GetPublicEventHandler( $this->eventRepository, + $this->occurrenceRepository, $this->promoCodeRepository, $this->ticketFilterService, $this->eventPageViewIncrementService ); } - public function testHandleWithoutPromoCodeAndUnauthenticatedUser(): void + public function test_handle_without_promo_code_and_unauthenticated_user(): void { $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: false, ipAddress: '127.0.0.1', promoCode: null); - $event = new EventDomainObject(); + $event = new EventDomainObject; $event->setProductCategories(collect()); $this->setupEventRepositoryMock($event, $data->eventId); @@ -52,10 +66,10 @@ public function testHandleWithoutPromoCodeAndUnauthenticatedUser(): void $this->handler->handle($data); } - public function testHandleWithInvalidPromoCode(): void + public function test_handle_with_invalid_promo_code(): void { $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: false, ipAddress: '127.0.0.1', promoCode: 'INVALID'); - $event = new EventDomainObject(); + $event = new EventDomainObject; $event->setProductCategories(collect()); $promoCode = m::mock(PromoCodeDomainObject::class)->makePartial(); $promoCode->shouldReceive('isValid')->andReturn(false); @@ -68,10 +82,10 @@ public function testHandleWithInvalidPromoCode(): void $this->handler->handle($data); } - public function testHandleWithValidPromoCode(): void + public function test_handle_with_valid_promo_code(): void { $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: false, ipAddress: '127.0.0.1', promoCode: 'VALID'); - $event = new EventDomainObject(); + $event = new EventDomainObject; $event->setProductCategories(collect()); $promoCode = m::mock(PromoCodeDomainObject::class)->makePartial(); $promoCode->shouldReceive('isValid')->andReturn(true); @@ -84,9 +98,170 @@ public function testHandleWithValidPromoCode(): void $this->handler->handle($data); } + public function test_handle_loads_single_event_occurrence_without_future_filter(): void + { + $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: true, ipAddress: '127.0.0.1', promoCode: null); + $event = (new EventDomainObject) + ->setType(EventType::SINGLE->name) + ->setProductCategories(collect()); + $pastOccurrence = $this->makeOccurrence(10, '2024-01-01 10:00:00'); + + $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf()->times(4); + $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event); + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->with( + m::on(static fn (array $where): bool => ! collect($where)->contains( + fn ($condition): bool => is_array($condition) + && ($condition[0] ?? null) === EventOccurrenceDomainObjectAbstract::START_DATE + )), + m::any(), + m::any(), + m::any(), + ) + ->andReturn(collect([$pastOccurrence])); + $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull(); + $this->ticketFilterService->shouldReceive('filter')->once()->withAnyArgs()->andReturn(collect()); + $this->eventPageViewIncrementService->shouldNotReceive('increment'); + + $result = $this->handler->handle($data); + + $this->assertTrue($result->getEventOccurrences()->contains( + fn (EventOccurrenceDomainObject $occurrence) => $occurrence->getId() === 10 + )); + } + + public function test_handle_ignores_requested_occurrence_when_it_does_not_belong_to_event(): void + { + $data = new GetPublicEventDTO( + eventId: 1, + isAuthenticated: true, + ipAddress: '127.0.0.1', + promoCode: null, + eventOccurrenceId: 999, + ); + $event = new EventDomainObject; + $event->setProductCategories(collect()); + + $this->setupEventRepositoryMock($event, $data->eventId); + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + EventOccurrenceDomainObjectAbstract::ID => 999, + EventOccurrenceDomainObjectAbstract::EVENT_ID => 1, + [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name], + ]) + ->andReturnNull(); + $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull(); + + $capturedOccurrenceId = 'not-called'; + $this->ticketFilterService + ->shouldReceive('filter') + ->once() + ->andReturnUsing(function ( + Collection $productsCategories, + ?PromoCodeDomainObject $promoCode = null, + bool $hideSoldOutProducts = true, + ?int $eventOccurrenceId = null, + ) use (&$capturedOccurrenceId) { + $capturedOccurrenceId = $eventOccurrenceId; + + return collect(); + }); + $this->eventPageViewIncrementService->shouldNotReceive('increment'); + + $this->handler->handle($data); + + $this->assertNull($capturedOccurrenceId); + } + + public function test_handle_keeps_requested_occurrence_outside_public_cap(): void + { + // The production query loads MAX_PUBLIC_OCCURRENCES + 1 (= 201) rows so + // the handler can detect overflow without paginating the whole + // recurrence series. A direct link to occurrence 5000 must therefore + // be resolved via findFirstWhere() and stitched back into the payload. + $linkedOccurrenceId = 5000; + $data = new GetPublicEventDTO( + eventId: 1, + isAuthenticated: true, + ipAddress: '127.0.0.1', + promoCode: null, + eventOccurrenceId: $linkedOccurrenceId, + ); + $event = new EventDomainObject; + $event->setProductCategories(collect()); + + $loadedLimit = GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1; + $occurrences = collect(range(1, $loadedLimit)) + ->map(fn (int $id) => $this->makeOccurrence($id, '2026-01-01 10:00:00')); + $linkedOccurrence = $this->makeOccurrence($linkedOccurrenceId, '2027-01-01 10:00:00'); + + $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf()->times(4); + $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event); + // findWhere has signature ($where, $columns, $orderAndDirections, $limit) + // — production passes the limit by name, but Mockery sees them as + // positional, so we need to assert against arg index 3. + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->with(m::any(), m::any(), m::any(), m::on(static fn ($limit) => $limit === $loadedLimit)) + ->andReturn($occurrences); + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + EventOccurrenceDomainObjectAbstract::ID => $linkedOccurrenceId, + EventOccurrenceDomainObjectAbstract::EVENT_ID => 1, + [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name], + ]) + ->andReturn($linkedOccurrence); + $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull(); + + $capturedOccurrenceId = null; + $this->ticketFilterService + ->shouldReceive('filter') + ->once() + ->andReturnUsing(function ( + Collection $productsCategories, + ?PromoCodeDomainObject $promoCode = null, + bool $hideSoldOutProducts = true, + ?int $eventOccurrenceId = null, + ) use (&$capturedOccurrenceId) { + $capturedOccurrenceId = $eventOccurrenceId; + + return collect(); + }); + $this->eventPageViewIncrementService->shouldNotReceive('increment'); + + $result = $this->handler->handle($data); + + $this->assertSame($linkedOccurrenceId, $capturedOccurrenceId); + $this->assertTrue( + $result->getEventOccurrences()->contains( + fn (EventOccurrenceDomainObject $occurrence) => $occurrence->getId() === $linkedOccurrenceId + ) + ); + // 200 capped + 1 linked appended = MAX_PUBLIC_OCCURRENCES + 1. + $this->assertCount(GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1, $result->getEventOccurrences()); + } + private function setupEventRepositoryMock($event, $eventId): void { $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf()->times(4); $this->eventRepository->shouldReceive('findById')->with($eventId)->andReturn($event); + $this->occurrenceRepository->shouldReceive('findWhere')->andReturn(collect()); + } + + private function makeOccurrence(int $id, string $startDate): EventOccurrenceDomainObject + { + return (new EventOccurrenceDomainObject) + ->setId($id) + ->setEventId(1) + ->setShortId((string) $id) + ->setStartDate($startDate) + ->setStatus(EventOccurrenceStatus::ACTIVE->name); } } diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandlerTest.php new file mode 100644 index 0000000000..67652975ef --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandlerTest.php @@ -0,0 +1,631 @@ +occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->orderItemRepository = Mockery::mock(OrderItemRepositoryInterface::class); + $this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class); + $this->waitlistEntryRepository = Mockery::mock(WaitlistEntryRepositoryInterface::class); + $this->exclusionService = Mockery::mock(RecurrenceRuleExclusionService::class); + $this->databaseManager = Mockery::mock(DatabaseManager::class); + + // Default: no attendees on any occurrence. Delete-path tests that need + // to assert the attendee guard override this expectation. + $this->attendeeRepository + ->shouldReceive('findWhereIn') + ->byDefault() + ->andReturn(new Collection); + + $this->databaseManager->shouldReceive('transaction') + ->andReturnUsing(fn ($callback) => $callback()); + + // Default: exclusion update is a no-op. The DELETE branch overrides this + // to assert the call. + $this->exclusionService->shouldReceive('addExclusions')->byDefault(); + + // Default: waitlist cleanup is a no-op. Tests targeting the cleanup + // branch override this expectation. + $this->waitlistEntryRepository + ->shouldReceive('updateWhere') + ->byDefault() + ->andReturn(0); + + // Capacity edits trigger SOLD_OUT/ACTIVE reconciliation via two extra + // scoped updateWheres on top of the main attribute update. They're + // a side effect — each test still asserts its specific main updateWhere + // explicitly, so default the reconciliation to a no-op here so it + // doesn't trigger NoMatchingExpectationException for unrelated tests. + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->with( + Mockery::on(fn (array $attrs) => array_keys($attrs) === [EventOccurrenceDomainObjectAbstract::STATUS]), + Mockery::any(), + ) + ->zeroOrMoreTimes() + ->byDefault(); + + $this->handler = new BulkUpdateOccurrencesHandler( + $this->occurrenceRepository, + $this->orderItemRepository, + $this->attendeeRepository, + $this->waitlistEntryRepository, + $this->exclusionService, + $this->databaseManager, + ); + } + + public function test_handle_updates_capacity_for_future_non_overridden_occurrences(): void + { + $dto = new BulkUpdateOccurrencesDTO( + event_id: 1, + action: BulkOccurrenceAction::UPDATE, + timezone: 'America/New_York', + capacity: 500, + future_only: true, + skip_overridden: true, + apply_to_all: true, + ); + + $futureOccurrence = $this->createOccurrenceMock(10, false, false); + $pastOccurrence = $this->createOccurrenceMock(11, true, false); + $overriddenOccurrence = $this->createOccurrenceMock(12, false, true); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(new Collection([$futureOccurrence, $pastOccurrence, $overriddenOccurrence])); + + // Capacity changes pin the occurrence as overridden so future regenerates + // don't reset it back to the rule's default. + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + [ + EventOccurrenceDomainObjectAbstract::CAPACITY => 500, + EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => true, + ], + [[EventOccurrenceDomainObjectAbstract::ID, 'in', [10]]] + ); + + $result = $this->handler->handle($dto); + + $this->assertEquals(1, $result->updated_count); + } + + public function test_handle_shifts_time_by_minutes(): void + { + // Occurrence stored as 09:00 UTC, shift forward by 60 minutes → 10:00 UTC + $dto = new BulkUpdateOccurrencesDTO( + event_id: 1, + action: BulkOccurrenceAction::UPDATE, + timezone: 'America/New_York', + start_time_shift: 60, + end_time_shift: 60, + future_only: false, + skip_overridden: false, + apply_to_all: true, + ); + + $occurrence = $this->createOccurrenceMock(10, false, false, '2026-03-01 14:00:00', '2026-03-01 16:00:00'); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(new Collection([$occurrence])); + + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + Mockery::on(function ($attributes) { + return $attributes[EventOccurrenceDomainObjectAbstract::START_DATE] === '2026-03-01 15:00:00' + && $attributes[EventOccurrenceDomainObjectAbstract::END_DATE] === '2026-03-01 17:00:00'; + }), + [EventOccurrenceDomainObjectAbstract::ID => 10] + ); + + $result = $this->handler->handle($dto); + + $this->assertEquals(1, $result->updated_count); + } + + public function test_time_shift_pins_as_overridden_so_regenerate_doesnt_revert_it(): void + { + // Without is_overridden, the next regenerate would treat the shifted + // occurrence as stale (no candidate at its new time) and delete it. + $dto = new BulkUpdateOccurrencesDTO( + event_id: 1, + action: BulkOccurrenceAction::UPDATE, + timezone: 'UTC', + start_time_shift: 60, + future_only: false, + skip_overridden: false, + apply_to_all: true, + ); + + $occurrence = $this->createOccurrenceMock(10, false, false, '2026-03-01 14:00:00', null); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(new Collection([$occurrence])); + + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === true), + Mockery::any(), + ); + + $result = $this->handler->handle($dto); + $this->assertEquals(1, $result->updated_count); + } + + public function test_handle_shifts_time_backwards(): void + { + // Shift backward by 30 minutes: 14:00 → 13:30, 16:00 → 15:30 + $dto = new BulkUpdateOccurrencesDTO( + event_id: 1, + action: BulkOccurrenceAction::UPDATE, + timezone: 'UTC', + start_time_shift: -30, + end_time_shift: -30, + future_only: false, + skip_overridden: false, + apply_to_all: true, + ); + + $occurrence = $this->createOccurrenceMock(10, false, false, '2026-03-01 14:00:00', '2026-03-01 16:00:00'); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(new Collection([$occurrence])); + + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + Mockery::on(function ($attributes) { + return $attributes[EventOccurrenceDomainObjectAbstract::START_DATE] === '2026-03-01 13:30:00' + && $attributes[EventOccurrenceDomainObjectAbstract::END_DATE] === '2026-03-01 15:30:00'; + }), + [EventOccurrenceDomainObjectAbstract::ID => 10] + ); + + $result = $this->handler->handle($dto); + + $this->assertEquals(1, $result->updated_count); + } + + public function test_handle_shifts_only_start_time_when_end_time_shift_is_null(): void + { + $dto = new BulkUpdateOccurrencesDTO( + event_id: 1, + action: BulkOccurrenceAction::UPDATE, + timezone: 'UTC', + start_time_shift: 90, + future_only: false, + skip_overridden: false, + apply_to_all: true, + ); + + $occurrence = $this->createOccurrenceMock(10, false, false, '2026-03-01 09:00:00', '2026-03-01 11:00:00'); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(new Collection([$occurrence])); + + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + Mockery::on(function ($attributes) { + return $attributes[EventOccurrenceDomainObjectAbstract::START_DATE] === '2026-03-01 10:30:00' + && ! array_key_exists(EventOccurrenceDomainObjectAbstract::END_DATE, $attributes); + }), + [EventOccurrenceDomainObjectAbstract::ID => 10] + ); + + $result = $this->handler->handle($dto); + + $this->assertEquals(1, $result->updated_count); + } + + public function test_handle_shift_times_does_not_add_end_date_when_null(): void + { + $dto = new BulkUpdateOccurrencesDTO( + event_id: 1, + action: BulkOccurrenceAction::UPDATE, + timezone: 'UTC', + start_time_shift: 60, + end_time_shift: 60, + future_only: false, + skip_overridden: false, + apply_to_all: true, + ); + + $occurrence = $this->createOccurrenceMock(10, false, false, '2026-03-01 14:00:00', null); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(new Collection([$occurrence])); + + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + Mockery::on(function ($attributes) { + return $attributes[EventOccurrenceDomainObjectAbstract::START_DATE] === '2026-03-01 15:00:00' + && ! array_key_exists(EventOccurrenceDomainObjectAbstract::END_DATE, $attributes); + }), + [EventOccurrenceDomainObjectAbstract::ID => 10] + ); + + $result = $this->handler->handle($dto); + + $this->assertEquals(1, $result->updated_count); + } + + public function test_handle_cancels_all_future_occurrences_via_job(): void + { + Bus::fake([BulkCancelOccurrencesJob::class]); + + $dto = new BulkUpdateOccurrencesDTO( + event_id: 1, + action: BulkOccurrenceAction::CANCEL, + timezone: 'UTC', + future_only: true, + skip_overridden: false, + refund_orders: true, + apply_to_all: true, + ); + + $futureOccurrence1 = $this->createOccurrenceMock(10, false, false, '2026-03-15 09:00:00'); + $futureOccurrence2 = $this->createOccurrenceMock(11, false, true, '2026-03-22 09:00:00'); + $pastOccurrence = $this->createOccurrenceMock(12, true, false); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(new Collection([$futureOccurrence1, $futureOccurrence2, $pastOccurrence])); + + $result = $this->handler->handle($dto); + + $this->assertEquals(2, $result->updated_count); + + Bus::assertDispatched(BulkCancelOccurrencesJob::class, function (BulkCancelOccurrencesJob $job) { + return $job->eventId === 1 + && $job->occurrenceIds === [10, 11] + && $job->refundOrders === true; + }); + } + + public function test_handle_skips_cancelled_occurrences(): void + { + $dto = new BulkUpdateOccurrencesDTO( + event_id: 1, + action: BulkOccurrenceAction::UPDATE, + timezone: 'UTC', + capacity: 100, + future_only: false, + skip_overridden: false, + apply_to_all: true, + ); + + $activeOccurrence = $this->createOccurrenceMock(10, false, false, '2026-03-01 09:00:00', null, EventOccurrenceStatus::ACTIVE->name); + $cancelledOccurrence = $this->createOccurrenceMock(11, false, false, '2026-03-02 09:00:00', null, EventOccurrenceStatus::CANCELLED->name); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(new Collection([$activeOccurrence, $cancelledOccurrence])); + + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + [ + EventOccurrenceDomainObjectAbstract::CAPACITY => 100, + EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => true, + ], + [[EventOccurrenceDomainObjectAbstract::ID, 'in', [10]]] + ); + + $result = $this->handler->handle($dto); + + $this->assertEquals(1, $result->updated_count); + } + + public function test_handle_returns_zero_when_no_fields_to_update(): void + { + $dto = new BulkUpdateOccurrencesDTO( + event_id: 1, + action: BulkOccurrenceAction::UPDATE, + timezone: 'UTC', + future_only: false, + skip_overridden: false, + apply_to_all: true, + ); + + $occurrence = $this->createOccurrenceMock(10, false, false); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(new Collection([$occurrence])); + + $this->occurrenceRepository + ->shouldNotReceive('updateWhere'); + + $result = $this->handler->handle($dto); + + $this->assertEquals(0, $result->updated_count); + } + + public function test_handle_clears_capacity(): void + { + $dto = new BulkUpdateOccurrencesDTO( + event_id: 1, + action: BulkOccurrenceAction::UPDATE, + timezone: 'UTC', + clear_capacity: true, + future_only: false, + skip_overridden: false, + apply_to_all: true, + ); + + $occurrence = $this->createOccurrenceMock(10, false, false); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(new Collection([$occurrence])); + + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + [ + EventOccurrenceDomainObjectAbstract::CAPACITY => null, + EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => true, + ], + [[EventOccurrenceDomainObjectAbstract::ID, 'in', [10]]] + ); + + $result = $this->handler->handle($dto); + + $this->assertEquals(1, $result->updated_count); + } + + public function test_handle_filters_to_specific_occurrence_ids(): void + { + $dto = new BulkUpdateOccurrencesDTO( + event_id: 1, + action: BulkOccurrenceAction::UPDATE, + timezone: 'UTC', + capacity: 200, + future_only: false, + skip_overridden: false, + occurrence_ids: [10, 12], + ); + + $occ10 = $this->createOccurrenceMock(10, false, false); + $occ11 = $this->createOccurrenceMock(11, false, false); + $occ12 = $this->createOccurrenceMock(12, false, false); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(new Collection([$occ10, $occ11, $occ12])); + + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + [ + EventOccurrenceDomainObjectAbstract::CAPACITY => 200, + EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => true, + ], + [[EventOccurrenceDomainObjectAbstract::ID, 'in', [10, 12]]] + ); + + $result = $this->handler->handle($dto); + + $this->assertEquals(2, $result->updated_count); + } + + public function test_handle_deletes_occurrences_without_orders(): void + { + $dto = new BulkUpdateOccurrencesDTO( + event_id: 1, + action: BulkOccurrenceAction::DELETE, + timezone: 'UTC', + future_only: false, + skip_overridden: false, + occurrence_ids: [10, 11], + ); + + $occNoOrders = $this->createOccurrenceMock(10, false, false, '2026-03-01 09:00:00'); + $occWithOrders = $this->createOccurrenceMock(11, false, false, '2026-03-08 09:00:00'); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(new Collection([$occNoOrders, $occWithOrders])); + + // Batched: a single findWhereIn returns one row per occurrence-with-orders. + // Occurrence 11 has orders, occurrence 10 doesn't. + $orderItem11 = Mockery::mock(OrderItemDomainObject::class); + $orderItem11->shouldReceive('getEventOccurrenceId')->andReturn(11); + + $this->orderItemRepository + ->shouldReceive('findWhereIn') + ->once() + ->withArgs(function ($field, $values, $additionalWhere = [], $columns = []) { + return $field === OrderItemDomainObjectAbstract::EVENT_OCCURRENCE_ID + && $values === [10, 11] + && $columns === [OrderItemDomainObjectAbstract::EVENT_OCCURRENCE_ID]; + }) + ->andReturn(new Collection([$orderItem11])); + + $this->occurrenceRepository + ->shouldReceive('deleteWhere') + ->once() + ->with([[EventOccurrenceDomainObjectAbstract::ID, 'in', [10]]]); + + $this->exclusionService + ->shouldReceive('addExclusions') + ->once() + ->with(1, ['2026-03-01 09:00:00']); + + $result = $this->handler->handle($dto); + + $this->assertEquals(1, $result->updated_count); + } + + public function test_handle_skips_deletion_for_occurrences_with_attendees_but_no_orders(): void + { + // Mirrors the single-delete handler's attendee guard. Attendees can + // exist without order_items in legacy/imported data, so checking only + // orders would soft-delete an occurrence that still has live attendees. + $dto = new BulkUpdateOccurrencesDTO( + event_id: 1, + action: BulkOccurrenceAction::DELETE, + timezone: 'UTC', + future_only: false, + skip_overridden: false, + occurrence_ids: [10, 11], + ); + + $occClean = $this->createOccurrenceMock(10, false, false, '2026-03-01 09:00:00'); + $occWithAttendees = $this->createOccurrenceMock(11, false, false, '2026-03-08 09:00:00'); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(new Collection([$occClean, $occWithAttendees])); + + // Batched orders lookup: no occurrences have orders. + $this->orderItemRepository + ->shouldReceive('findWhereIn') + ->once() + ->withArgs(function ($field, $values, $additionalWhere = [], $columns = []) { + return $field === OrderItemDomainObjectAbstract::EVENT_OCCURRENCE_ID + && $values === [10, 11] + && $columns === [OrderItemDomainObjectAbstract::EVENT_OCCURRENCE_ID]; + }) + ->andReturn(new Collection); + + // Override default attendee no-op: occurrence 11 has attendees and + // must be excluded from the delete batch even though it has no orders. + $attendee11 = Mockery::mock(AttendeeDomainObject::class); + $attendee11->shouldReceive('getEventOccurrenceId')->andReturn(11); + + $this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class); + $this->attendeeRepository + ->shouldReceive('findWhereIn') + ->once() + ->withArgs(function ($field, $values, $additionalWhere = [], $columns = []) { + return $field === AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID + && $values === [10, 11] + && $columns === [AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID]; + }) + ->andReturn(new Collection([$attendee11])); + + $this->handler = new BulkUpdateOccurrencesHandler( + $this->occurrenceRepository, + $this->orderItemRepository, + $this->attendeeRepository, + $this->waitlistEntryRepository, + $this->exclusionService, + $this->databaseManager, + ); + + $this->occurrenceRepository + ->shouldReceive('deleteWhere') + ->once() + ->with([[EventOccurrenceDomainObjectAbstract::ID, 'in', [10]]]); + + $this->exclusionService + ->shouldReceive('addExclusions') + ->once() + ->with(1, ['2026-03-01 09:00:00']); + + $result = $this->handler->handle($dto); + + $this->assertEquals(1, $result->updated_count); + } + + private function createOccurrenceMock( + int $id, + bool $isPast, + bool $isOverridden, + string $startDate = '2026-03-01 09:00:00', + ?string $endDate = '2026-03-01 11:00:00', + string $status = 'ACTIVE', + ): EventOccurrenceDomainObject|MockInterface { + $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); + $occurrence->shouldReceive('isPast')->andReturn($isPast); + $occurrence->shouldReceive('getIsOverridden')->andReturn($isOverridden); + $occurrence->shouldReceive('getId')->andReturn($id); + $occurrence->shouldReceive('getStatus')->andReturn($status); + $occurrence->shouldReceive('getStartDate')->andReturn($startDate); + $occurrence->shouldReceive('getEndDate')->andReturn($endDate); + + return $occurrence; + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/CancelOccurrenceHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/CancelOccurrenceHandlerTest.php new file mode 100644 index 0000000000..53478ee141 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/CancelOccurrenceHandlerTest.php @@ -0,0 +1,273 @@ +occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->exclusionService = Mockery::mock(RecurrenceRuleExclusionService::class); + $this->cancelAttendeesService = Mockery::mock(CancelOccurrenceAttendeesService::class); + $this->databaseManager = Mockery::mock(DatabaseManager::class); + + $this->databaseManager->shouldReceive('transaction') + ->andReturnUsing(fn ($callback) => $callback()); + + $this->handler = new CancelOccurrenceHandler( + $this->occurrenceRepository, + $this->exclusionService, + $this->cancelAttendeesService, + $this->databaseManager, + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + private function expectAttendeeCancelCalled(int $eventId, int $occurrenceId): void + { + $this->cancelAttendeesService + ->shouldReceive('cancelForOccurrence') + ->once() + ->with($eventId, $occurrenceId); + } + + public function test_handle_sets_status_to_cancelled(): void + { + $eventId = 1; + $occurrenceId = 10; + + $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); + $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00'); + $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name); + $occurrence->shouldReceive('getEventId')->andReturn($eventId); + + $updatedOccurrence = Mockery::mock(EventOccurrenceDomainObject::class); + + $this->occurrenceRepository + ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn($occurrence); + $this->occurrenceRepository + ->shouldReceive('updateFromArray') + ->once() + ->with($occurrenceId, [ + EventOccurrenceDomainObjectAbstract::STATUS => EventOccurrenceStatus::CANCELLED->name, + ]) + ->andReturn($updatedOccurrence); + $this->expectAttendeeCancelCalled($eventId, $occurrenceId); + $this->exclusionService + ->shouldReceive('addExclusions') + ->once() + ->with($eventId, ['2026-06-15 10:00:00']); + + $result = $this->handler->handle($eventId, $occurrenceId); + + $this->assertSame($updatedOccurrence, $result); + + Event::assertDispatched(OccurrenceCancelledEvent::class, function ($e) use ($eventId, $occurrenceId) { + return $e->eventId === $eventId && $e->occurrenceId === $occurrenceId; + }); + } + + public function test_handle_delegates_recurrence_exclusion_with_occurrence_start_date(): void + { + $eventId = 1; + $occurrenceId = 10; + + $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); + $occurrence->shouldReceive('getStartDate')->andReturn('2026-07-20 14:00:00'); + $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name); + $occurrence->shouldReceive('getEventId')->andReturn($eventId); + + $updatedOccurrence = Mockery::mock(EventOccurrenceDomainObject::class); + + $this->occurrenceRepository + ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn($occurrence); + $this->occurrenceRepository + ->shouldReceive('updateFromArray')->once()->andReturn($updatedOccurrence); + $this->expectAttendeeCancelCalled($eventId, $occurrenceId); + $this->exclusionService + ->shouldReceive('addExclusions') + ->once() + ->with($eventId, ['2026-07-20 14:00:00']); + + $result = $this->handler->handle($eventId, $occurrenceId); + + $this->assertSame($updatedOccurrence, $result); + } + + public function test_handle_throws_exception_when_occurrence_not_found(): void + { + $eventId = 1; + $occurrenceId = 999; + + $this->occurrenceRepository + ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn(null); + + $this->occurrenceRepository->shouldNotReceive('updateFromArray'); + $this->exclusionService->shouldNotReceive('addExclusions'); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle($eventId, $occurrenceId); + + Event::assertNotDispatched(OccurrenceCancelledEvent::class); + } + + public function test_handle_throws_when_occurrence_belongs_to_different_event(): void + { + $requestedEventId = 1; + $foreignEventId = 999; + $occurrenceId = 10; + + $foreignOccurrence = Mockery::mock(EventOccurrenceDomainObject::class); + $foreignOccurrence->shouldReceive('getEventId')->andReturn($foreignEventId); + + $this->occurrenceRepository + ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn($foreignOccurrence); + + $this->occurrenceRepository->shouldNotReceive('updateFromArray'); + $this->exclusionService->shouldNotReceive('addExclusions'); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle($requestedEventId, $occurrenceId); + + Event::assertNotDispatched(OccurrenceCancelledEvent::class); + } + + public function test_handle_dispatches_event_with_refund_flag_true(): void + { + $eventId = 1; + $occurrenceId = 10; + + $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); + $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00'); + $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name); + $occurrence->shouldReceive('getEventId')->andReturn($eventId); + + $updatedOccurrence = Mockery::mock(EventOccurrenceDomainObject::class); + + $this->occurrenceRepository + ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn($occurrence); + $this->occurrenceRepository + ->shouldReceive('updateFromArray')->once()->andReturn($updatedOccurrence); + $this->expectAttendeeCancelCalled($eventId, $occurrenceId); + $this->exclusionService->shouldReceive('addExclusions')->once(); + + $this->handler->handle($eventId, $occurrenceId, refundOrders: true); + + Event::assertDispatched(OccurrenceCancelledEvent::class, function ($e) use ($eventId, $occurrenceId) { + return $e->eventId === $eventId + && $e->occurrenceId === $occurrenceId + && $e->refundOrders === true; + }); + } + + public function test_handle_dispatches_event_with_refund_flag_false(): void + { + $eventId = 1; + $occurrenceId = 10; + + $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); + $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00'); + $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name); + $occurrence->shouldReceive('getEventId')->andReturn($eventId); + + $updatedOccurrence = Mockery::mock(EventOccurrenceDomainObject::class); + + $this->occurrenceRepository + ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn($occurrence); + $this->occurrenceRepository + ->shouldReceive('updateFromArray')->once()->andReturn($updatedOccurrence); + $this->expectAttendeeCancelCalled($eventId, $occurrenceId); + $this->exclusionService->shouldReceive('addExclusions')->once(); + + $this->handler->handle($eventId, $occurrenceId, refundOrders: false); + + Event::assertDispatched(OccurrenceCancelledEvent::class, function ($e) { + return $e->refundOrders === false; + }); + } + + public function test_it_returns_early_if_occurrence_already_cancelled(): void + { + $eventId = 1; + $occurrenceId = 10; + + $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); + $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::CANCELLED->name); + $occurrence->shouldReceive('getEventId')->andReturn($eventId); + + $this->occurrenceRepository + ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn($occurrence); + + $this->occurrenceRepository->shouldNotReceive('updateFromArray'); + $this->cancelAttendeesService->shouldNotReceive('cancelForOccurrence'); + $this->exclusionService->shouldNotReceive('addExclusions'); + + $result = $this->handler->handle($eventId, $occurrenceId, refundOrders: true); + + $this->assertSame($occurrence, $result); + + Event::assertNotDispatched(OccurrenceCancelledEvent::class); + } + + public function test_delegates_attendee_cancellation_to_service(): void + { + $eventId = 1; + $occurrenceId = 10; + + $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); + $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00'); + $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name); + $occurrence->shouldReceive('getEventId')->andReturn($eventId); + + $this->occurrenceRepository + ->shouldReceive('findByIdLocked')->once()->andReturn($occurrence); + $this->occurrenceRepository + ->shouldReceive('updateFromArray')->once()->andReturn(Mockery::mock(EventOccurrenceDomainObject::class)); + + $this->cancelAttendeesService + ->shouldReceive('cancelForOccurrence') + ->once() + ->with($eventId, $occurrenceId); + + $this->exclusionService->shouldReceive('addExclusions')->once(); + + $result = $this->handler->handle($eventId, $occurrenceId); + $this->assertNotNull($result); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandlerTest.php new file mode 100644 index 0000000000..1c70a0aba5 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandlerTest.php @@ -0,0 +1,107 @@ +occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->databaseManager = Mockery::mock(DatabaseManager::class); + + $this->databaseManager->shouldReceive('transaction') + ->andReturnUsing(fn($callback) => $callback()); + + $this->handler = new CreateEventOccurrenceHandler( + $this->occurrenceRepository, + $this->databaseManager, + ); + } + + public function testHandleSuccessfullyCreatesOccurrence(): void + { + $dto = new UpsertEventOccurrenceDTO( + event_id: 1, + start_date: '2026-06-01 10:00:00', + end_date: '2026-06-01 18:00:00', + capacity: 100, + label: 'Morning Session', + is_overridden: false, + ); + + $expectedOccurrence = Mockery::mock(EventOccurrenceDomainObject::class); + + $this->occurrenceRepository + ->shouldReceive('create') + ->once() + ->with(Mockery::on(function ($attrs) { + return $attrs[EventOccurrenceDomainObjectAbstract::EVENT_ID] === 1 + && $attrs[EventOccurrenceDomainObjectAbstract::START_DATE] === '2026-06-01 10:00:00' + && $attrs[EventOccurrenceDomainObjectAbstract::END_DATE] === '2026-06-01 18:00:00' + && $attrs[EventOccurrenceDomainObjectAbstract::STATUS] === EventOccurrenceStatus::ACTIVE->name + && $attrs[EventOccurrenceDomainObjectAbstract::CAPACITY] === 100 + && $attrs[EventOccurrenceDomainObjectAbstract::USED_CAPACITY] === 0 + && $attrs[EventOccurrenceDomainObjectAbstract::LABEL] === 'Morning Session' + && str_starts_with($attrs[EventOccurrenceDomainObjectAbstract::SHORT_ID], 'oc_'); + })) + ->andReturn($expectedOccurrence); + + $result = $this->handler->handle($dto); + + $this->assertSame($expectedOccurrence, $result); + } + + public function testHandleAlwaysCreatesOccurrencesAsActive(): void + { + // Status is no longer client-controllable on create — it always starts + // ACTIVE. Cancellation goes through CancelOccurrenceHandler, SOLD_OUT + // is computed by ProductQuantityUpdateService. + $dto = new UpsertEventOccurrenceDTO( + event_id: 2, + start_date: '2026-07-01 09:00:00', + end_date: null, + capacity: null, + ); + + $expectedOccurrence = Mockery::mock(EventOccurrenceDomainObject::class); + + $this->occurrenceRepository + ->shouldReceive('create') + ->once() + ->with(Mockery::on(function ($attrs) { + return $attrs[EventOccurrenceDomainObjectAbstract::EVENT_ID] === 2 + && $attrs[EventOccurrenceDomainObjectAbstract::START_DATE] === '2026-07-01 09:00:00' + && $attrs[EventOccurrenceDomainObjectAbstract::END_DATE] === null + && $attrs[EventOccurrenceDomainObjectAbstract::STATUS] === EventOccurrenceStatus::ACTIVE->name + && str_starts_with($attrs[EventOccurrenceDomainObjectAbstract::SHORT_ID], 'oc_'); + })) + ->andReturn($expectedOccurrence); + + $result = $this->handler->handle($dto); + + $this->assertSame($expectedOccurrence, $result); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/DeleteEventOccurrenceHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/DeleteEventOccurrenceHandlerTest.php new file mode 100644 index 0000000000..315b16170f --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/DeleteEventOccurrenceHandlerTest.php @@ -0,0 +1,305 @@ +occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->eventRepository = Mockery::mock(EventRepositoryInterface::class); + $this->orderItemRepository = Mockery::mock(OrderItemRepositoryInterface::class); + $this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class); + $this->waitlistEntryRepository = Mockery::mock(WaitlistEntryRepositoryInterface::class); + $this->databaseManager = Mockery::mock(DatabaseManager::class); + + // Default: waitlist cleanup is a no-op (no entries). Tests that + // exercise the cleanup branch override this. + $this->waitlistEntryRepository + ->shouldReceive('updateWhere') + ->byDefault() + ->andReturn(0); + + $this->databaseManager->shouldReceive('transaction') + ->andReturnUsing(fn ($callback) => $callback()); + + $this->handler = new DeleteEventOccurrenceHandler( + $this->occurrenceRepository, + $this->eventRepository, + $this->orderItemRepository, + $this->attendeeRepository, + $this->waitlistEntryRepository, + $this->databaseManager, + ); + } + + public function test_handle_successfully_deletes_occurrence_with_no_orders(): void + { + $eventId = 1; + $occurrenceId = 10; + + $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); + $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00'); + + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + EventOccurrenceDomainObjectAbstract::ID => $occurrenceId, + EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId, + ]) + ->andReturn($occurrence); + + $this->orderItemRepository + ->shouldReceive('countWhere') + ->once() + ->with(['event_occurrence_id' => $occurrenceId]) + ->andReturn(0); + + $this->attendeeRepository + ->shouldReceive('countWhere') + ->once() + ->with(['event_occurrence_id' => $occurrenceId]) + ->andReturn(0); + + $this->occurrenceRepository + ->shouldReceive('deleteWhere') + ->once() + ->with([ + EventOccurrenceDomainObjectAbstract::ID => $occurrenceId, + ]); + + // Non-recurring event: excluded_dates step is a no-op. + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getType')->andReturn(EventType::SINGLE->name); + $this->eventRepository + ->shouldReceive('findByIdLocked')->once()->andReturn($event); + $this->eventRepository->shouldNotReceive('updateFromArray'); + + $this->handler->handle($eventId, $occurrenceId); + + $this->assertTrue(true); + } + + public function test_delete_adds_date_to_recurrence_excluded_dates_for_recurring_event(): void + { + // Without this, the next regenerate would parse the rule, see the same + // candidate date, and recreate the deleted occurrence — silently + // undoing the delete. + $eventId = 1; + $occurrenceId = 10; + + $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); + $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00'); + + $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($occurrence); + $this->orderItemRepository->shouldReceive('countWhere')->once()->andReturn(0); + $this->attendeeRepository->shouldReceive('countWhere')->once()->andReturn(0); + $this->occurrenceRepository->shouldReceive('deleteWhere')->once(); + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getType')->andReturn(EventType::RECURRING->name); + $event->shouldReceive('getTimezone')->andReturn('UTC'); + $event->shouldReceive('getRecurrenceRule')->andReturn([ + 'excluded_dates' => ['2026-05-01'], + ]); + + $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event); + $this->eventRepository + ->shouldReceive('updateFromArray') + ->once() + ->with($eventId, Mockery::on(static function (array $attrs): bool { + $rule = $attrs[EventDomainObjectAbstract::RECURRENCE_RULE] ?? null; + + return is_array($rule) + && $rule['excluded_dates'] === ['2026-05-01'] + && $rule['excluded_occurrences'] === ['2026-06-15 10:00']; + })) + ->andReturn(Mockery::mock(EventDomainObject::class)); + + $this->handler->handle($eventId, $occurrenceId); + + $this->assertTrue(true); + } + + public function test_delete_does_not_duplicate_existing_excluded_date(): void + { + $eventId = 1; + $occurrenceId = 10; + + $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); + $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00'); + + $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($occurrence); + $this->orderItemRepository->shouldReceive('countWhere')->once()->andReturn(0); + $this->attendeeRepository->shouldReceive('countWhere')->once()->andReturn(0); + $this->occurrenceRepository->shouldReceive('deleteWhere')->once(); + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getType')->andReturn(EventType::RECURRING->name); + $event->shouldReceive('getTimezone')->andReturn('UTC'); + $event->shouldReceive('getRecurrenceRule')->andReturn([ + 'excluded_dates' => ['2026-06-15'], + 'excluded_occurrences' => ['2026-06-15 10:00'], + ]); + + $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event); + // No event update — the date is already excluded. + $this->eventRepository->shouldNotReceive('updateFromArray'); + + $this->handler->handle($eventId, $occurrenceId); + + $this->assertTrue(true); + } + + public function test_handle_throws_validation_exception_when_occurrence_has_orders(): void + { + $eventId = 1; + $occurrenceId = 10; + + $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); + + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + EventOccurrenceDomainObjectAbstract::ID => $occurrenceId, + EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId, + ]) + ->andReturn($occurrence); + + $this->orderItemRepository + ->shouldReceive('countWhere') + ->once() + ->with(['event_occurrence_id' => $occurrenceId]) + ->andReturn(5); + + $this->occurrenceRepository + ->shouldNotReceive('deleteWhere'); + + $this->expectException(ValidationException::class); + + $this->handler->handle($eventId, $occurrenceId); + } + + public function test_handle_throws_exception_when_occurrence_not_found(): void + { + $eventId = 1; + $occurrenceId = 999; + + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + EventOccurrenceDomainObjectAbstract::ID => $occurrenceId, + EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId, + ]) + ->andReturn(null); + + $this->orderItemRepository + ->shouldNotReceive('countWhere'); + + $this->occurrenceRepository + ->shouldNotReceive('deleteWhere'); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle($eventId, $occurrenceId); + } + + public function test_delete_cancels_waiting_and_offered_waitlist_entries_for_occurrence(): void + { + // The FK is nullOnDelete — without an explicit cancel here, WAITING + // entries pointing at this occurrence would be left as orphans (their + // event_occurrence_id nulled), which crashes ProcessWaitlistService + // on the next CapacityChangedEvent for recurring events. + $eventId = 1; + $occurrenceId = 10; + + $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); + $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00'); + + $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($occurrence); + $this->orderItemRepository->shouldReceive('countWhere')->once()->andReturn(0); + $this->attendeeRepository->shouldReceive('countWhere')->once()->andReturn(0); + $this->occurrenceRepository->shouldReceive('deleteWhere')->once(); + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getType')->andReturn(EventType::SINGLE->name); + $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event); + $this->eventRepository->shouldNotReceive('updateFromArray'); + + $this->waitlistEntryRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + Mockery::on(static function (array $attrs): bool { + return $attrs === ['status' => WaitlistEntryStatus::CANCELLED->name]; + }), + Mockery::on(static function (array $where) use ($eventId, $occurrenceId): bool { + if (($where['event_id'] ?? null) !== $eventId) { + return false; + } + if (($where['event_occurrence_id'] ?? null) !== $occurrenceId) { + return false; + } + foreach ($where as $clause) { + if (is_array($clause) && $clause[0] === 'status' && $clause[1] === 'in') { + return $clause[2] === [ + WaitlistEntryStatus::WAITING->name, + WaitlistEntryStatus::OFFERED->name, + ]; + } + } + + return false; + }), + ) + ->andReturn(0); + + $this->handler->handle($eventId, $occurrenceId); + + $this->assertTrue(true); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GenerateOccurrencesFromRuleHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GenerateOccurrencesFromRuleHandlerTest.php new file mode 100644 index 0000000000..b3926878cc --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GenerateOccurrencesFromRuleHandlerTest.php @@ -0,0 +1,133 @@ +generatorService = Mockery::mock(EventOccurrenceGeneratorService::class); + $this->eventRepository = Mockery::mock(EventRepositoryInterface::class); + $this->ruleParserService = Mockery::mock(RecurrenceRuleParserService::class); + $this->databaseManager = Mockery::mock(DatabaseManager::class); + + $this->databaseManager->shouldReceive('transaction') + ->andReturnUsing(fn($callback) => $callback()); + + $this->handler = new GenerateOccurrencesFromRuleHandler( + $this->generatorService, + $this->eventRepository, + $this->ruleParserService, + $this->databaseManager, + ); + } + + public function testHandleGeneratesOccurrencesAndUpdatesEventType(): void + { + $rule = ['frequency' => 'weekly', 'range' => ['type' => 'count', 'count' => 10]]; + $dto = new GenerateOccurrencesDTO(event_id: 1, recurrence_rule: $rule); + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getTimezone')->andReturn('America/New_York'); + $event->shouldReceive('getId')->andReturn(1); + $event->shouldReceive('setRecurrenceRule')->once()->with($rule); + + $this->eventRepository->shouldReceive('findById')->with(1)->once()->andReturn($event); + + $this->ruleParserService->shouldReceive('parse') + ->with($rule, 'America/New_York') + ->once() + ->andReturn(collect(range(1, 10))); + + $this->eventRepository->shouldReceive('updateFromArray') + ->once() + ->with(1, [ + EventDomainObjectAbstract::RECURRENCE_RULE => $rule, + EventDomainObjectAbstract::TYPE => EventType::RECURRING->name, + ]); + + $generatedOccurrences = collect(['occ1', 'occ2']); + $this->generatorService->shouldReceive('generate') + ->once() + ->with($event, $rule) + ->andReturn($generatedOccurrences); + + $result = $this->handler->handle($dto); + + $this->assertSame($generatedOccurrences, $result); + } + + public function testHandleThrowsValidationExceptionWhenTooManyOccurrences(): void + { + $rule = ['frequency' => 'daily', 'range' => ['type' => 'count', 'count' => 2000]]; + $dto = new GenerateOccurrencesDTO(event_id: 1, recurrence_rule: $rule); + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getTimezone')->andReturn('UTC'); + + $this->eventRepository->shouldReceive('findById')->with(1)->once()->andReturn($event); + + $this->ruleParserService->shouldReceive('parse') + ->with($rule, 'UTC') + ->once() + ->andReturn(collect(range(1, RecurrenceRuleParserService::MAX_OCCURRENCES + 1))); + + $this->generatorService->shouldNotReceive('generate'); + + $this->expectException(ValidationException::class); + + $this->handler->handle($dto); + } + + public function testHandleUsesUtcWhenEventHasNoTimezone(): void + { + $rule = ['frequency' => 'weekly']; + $dto = new GenerateOccurrencesDTO(event_id: 1, recurrence_rule: $rule); + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getTimezone')->andReturn(null); + $event->shouldReceive('getId')->andReturn(1); + $event->shouldReceive('setRecurrenceRule')->once(); + + $this->eventRepository->shouldReceive('findById')->once()->andReturn($event); + + $this->ruleParserService->shouldReceive('parse') + ->with($rule, 'UTC') + ->once() + ->andReturn(collect(range(1, 5))); + + $this->eventRepository->shouldReceive('updateFromArray')->once(); + $this->generatorService->shouldReceive('generate')->once()->andReturn(collect()); + + $result = $this->handler->handle($dto); + + $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandlerTest.php new file mode 100644 index 0000000000..4da7f1d134 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandlerTest.php @@ -0,0 +1,73 @@ +occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->handler = new GetEventOccurrenceHandler($this->occurrenceRepository); + } + + public function testHandleReturnsOccurrenceWithStats(): void + { + $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); + + $this->occurrenceRepository + ->shouldReceive('loadRelation') + ->with(EventOccurrenceStatisticDomainObject::class) + ->once() + ->andReturnSelf(); + + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + EventOccurrenceDomainObjectAbstract::ID => 10, + EventOccurrenceDomainObjectAbstract::EVENT_ID => 1, + ]) + ->andReturn($occurrence); + + $result = $this->handler->handle(1, 10); + + $this->assertSame($occurrence, $result); + } + + public function testHandleThrowsWhenOccurrenceNotFound(): void + { + $this->occurrenceRepository + ->shouldReceive('loadRelation') + ->once() + ->andReturnSelf(); + + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturn(null); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle(1, 999); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandlerTest.php new file mode 100644 index 0000000000..e5e208e1d2 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandlerTest.php @@ -0,0 +1,53 @@ +occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->handler = new GetEventOccurrencesHandler($this->occurrenceRepository); + } + + public function testHandleReturnsPaginatedOccurrencesWithStats(): void + { + $queryParams = Mockery::mock(QueryParamsDTO::class); + $paginator = Mockery::mock(LengthAwarePaginator::class); + + $this->occurrenceRepository + ->shouldReceive('loadRelation') + ->with(EventOccurrenceStatisticDomainObject::class) + ->once() + ->andReturnSelf(); + + $this->occurrenceRepository + ->shouldReceive('findByEventId') + ->once() + ->with(1, $queryParams) + ->andReturn($paginator); + + $result = $this->handler->handle(1, $queryParams); + + $this->assertSame($paginator, $result); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetProductVisibilityHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetProductVisibilityHandlerTest.php new file mode 100644 index 0000000000..7f82080cc5 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetProductVisibilityHandlerTest.php @@ -0,0 +1,68 @@ +visibilityRepository = Mockery::mock(ProductOccurrenceVisibilityRepositoryInterface::class); + $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->handler = new GetProductVisibilityHandler($this->visibilityRepository, $this->occurrenceRepository); + } + + public function testHandleReturnsVisibilityRecords(): void + { + $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); + + $this->occurrenceRepository->shouldReceive('findFirstWhere') + ->once() + ->with([ + EventOccurrenceDomainObjectAbstract::ID => 10, + EventOccurrenceDomainObjectAbstract::EVENT_ID => 1, + ]) + ->andReturn($occurrence); + + $records = collect([Mockery::mock(ProductOccurrenceVisibilityDomainObject::class)]); + $this->visibilityRepository->shouldReceive('findWhere') + ->once() + ->with([ProductOccurrenceVisibilityDomainObjectAbstract::EVENT_OCCURRENCE_ID => 10]) + ->andReturn($records); + + $result = $this->handler->handle(1, 10); + + $this->assertCount(1, $result); + } + + public function testHandleThrowsWhenOccurrenceNotFound(): void + { + $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn(null); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle(1, 999); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/DeletePriceOverrideHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/DeletePriceOverrideHandlerTest.php new file mode 100644 index 0000000000..b3589ae332 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/DeletePriceOverrideHandlerTest.php @@ -0,0 +1,197 @@ +overrideRepository = Mockery::mock(ProductPriceOccurrenceOverrideRepositoryInterface::class); + $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->databaseManager = Mockery::mock(DatabaseManager::class); + + $this->databaseManager->shouldReceive('transaction') + ->andReturnUsing(fn($callback) => $callback()); + + $this->handler = new DeletePriceOverrideHandler( + $this->overrideRepository, + $this->occurrenceRepository, + $this->databaseManager, + ); + } + + public function testHandleSuccessfullyDeletesOverrideScopedToOccurrence(): void + { + $eventId = 1; + $occurrenceId = 10; + $overrideId = 5; + + $existingOccurrence = Mockery::mock(EventOccurrenceDomainObject::class); + $existingOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class); + + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + EventOccurrenceDomainObjectAbstract::ID => $occurrenceId, + EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId, + ]) + ->andReturn($existingOccurrence); + + $this->overrideRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + ProductPriceOccurrenceOverrideDomainObjectAbstract::ID => $overrideId, + ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrenceId, + ]) + ->andReturn($existingOverride); + + $this->overrideRepository + ->shouldReceive('deleteWhere') + ->once() + ->with([ + ProductPriceOccurrenceOverrideDomainObjectAbstract::ID => $overrideId, + ]); + + $this->handler->handle($eventId, $occurrenceId, $overrideId); + + $this->assertTrue(true); + } + + public function testHandleThrowsExceptionWhenOccurrenceDoesNotBelongToEvent(): void + { + $eventId = 1; + $occurrenceId = 10; + $overrideId = 5; + + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + EventOccurrenceDomainObjectAbstract::ID => $occurrenceId, + EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId, + ]) + ->andReturn(null); + + $this->overrideRepository->shouldNotReceive('findFirstWhere'); + $this->overrideRepository->shouldNotReceive('deleteWhere'); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle($eventId, $occurrenceId, $overrideId); + } + + public function testHandleThrowsExceptionWhenOverrideNotFound(): void + { + $eventId = 1; + $occurrenceId = 10; + $overrideId = 999; + + $existingOccurrence = Mockery::mock(EventOccurrenceDomainObject::class); + + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturn($existingOccurrence); + + $this->overrideRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + ProductPriceOccurrenceOverrideDomainObjectAbstract::ID => $overrideId, + ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrenceId, + ]) + ->andReturn(null); + + $this->overrideRepository->shouldNotReceive('deleteWhere'); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle($eventId, $occurrenceId, $overrideId); + } + + public function testHandleScopesLookupToOccurrenceId(): void + { + $eventId = 1; + $occurrenceId = 42; + $overrideId = 7; + + $existingOccurrence = Mockery::mock(EventOccurrenceDomainObject::class); + + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturn($existingOccurrence); + + $this->overrideRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(Mockery::on(function ($arg) use ($occurrenceId, $overrideId) { + return $arg[ProductPriceOccurrenceOverrideDomainObjectAbstract::ID] === $overrideId + && $arg[ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID] === $occurrenceId; + })) + ->andReturn(null); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle($eventId, $occurrenceId, $overrideId); + } + + public function testHandleDeletesOnlyTheSpecifiedOverride(): void + { + $eventId = 1; + $occurrenceId = 10; + $overrideId = 3; + + $existingOccurrence = Mockery::mock(EventOccurrenceDomainObject::class); + $existingOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class); + + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturn($existingOccurrence); + + $this->overrideRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturn($existingOverride); + + $this->overrideRepository + ->shouldReceive('deleteWhere') + ->once() + ->with([ + ProductPriceOccurrenceOverrideDomainObjectAbstract::ID => $overrideId, + ]); + + $this->handler->handle($eventId, $occurrenceId, $overrideId); + + $this->assertTrue(true); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/GetPriceOverridesHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/GetPriceOverridesHandlerTest.php new file mode 100644 index 0000000000..b747358f6a --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/GetPriceOverridesHandlerTest.php @@ -0,0 +1,88 @@ +overrideRepository = Mockery::mock(ProductPriceOccurrenceOverrideRepositoryInterface::class); + $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->handler = new GetPriceOverridesHandler($this->overrideRepository, $this->occurrenceRepository); + } + + private function mockOccurrenceOwnership(): void + { + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class)); + } + + public function testHandleReturnsCollectionOfOverridesForOccurrence(): void + { + $this->mockOccurrenceOwnership(); + + $override1 = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class); + $override2 = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class); + $expectedCollection = new Collection([$override1, $override2]); + + $this->overrideRepository + ->shouldReceive('findWhere') + ->once() + ->with([ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => 10]) + ->andReturn($expectedCollection); + + $result = $this->handler->handle(1, 10); + + $this->assertCount(2, $result); + $this->assertSame($expectedCollection, $result); + } + + public function testHandleReturnsEmptyCollectionWhenNoneExist(): void + { + $this->mockOccurrenceOwnership(); + + $this->overrideRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(new Collection()); + + $result = $this->handler->handle(1, 99); + + $this->assertTrue($result->isEmpty()); + } + + public function testHandleThrowsWhenOccurrenceDoesNotBelongToEvent(): void + { + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->andReturn(null); + + $this->expectException(\HiEvents\Exceptions\ResourceNotFoundException::class); + + $this->handler->handle(1, 999); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandlerTest.php new file mode 100644 index 0000000000..d92dca0fb7 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandlerTest.php @@ -0,0 +1,329 @@ +overrideRepository = Mockery::mock(ProductPriceOccurrenceOverrideRepositoryInterface::class); + $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->productPriceRepository = Mockery::mock(ProductPriceRepositoryInterface::class); + $this->productRepository = Mockery::mock(ProductRepositoryInterface::class); + $this->databaseManager = Mockery::mock(DatabaseManager::class); + + $this->databaseManager->shouldReceive('transaction') + ->andReturnUsing(fn($callback) => $callback()); + + $this->handler = new UpsertPriceOverrideHandler( + $this->overrideRepository, + $this->occurrenceRepository, + $this->productPriceRepository, + $this->productRepository, + $this->databaseManager, + ); + } + + private function mockOwnershipChecks(): void + { + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class)); + + $priceMock = Mockery::mock(ProductPriceDomainObject::class); + $priceMock->shouldReceive('getProductId')->andReturn(5); + $this->productPriceRepository + ->shouldReceive('findFirst') + ->andReturn($priceMock); + + $this->productRepository + ->shouldReceive('findFirstWhere') + ->andReturn(Mockery::mock(ProductDomainObject::class)); + } + + public function testHandleCreatesNewOverrideWhenNoneExists(): void + { + $this->mockOwnershipChecks(); + + $dto = new UpsertPriceOverrideDTO( + event_id: 1, + event_occurrence_id: 10, + product_price_id: 20, + price: 99.99, + ); + + $expectedOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class); + + $this->overrideRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => 10, + ProductPriceOccurrenceOverrideDomainObjectAbstract::PRODUCT_PRICE_ID => 20, + ]) + ->andReturn(null); + + $this->overrideRepository + ->shouldReceive('create') + ->once() + ->with([ + ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => 10, + ProductPriceOccurrenceOverrideDomainObjectAbstract::PRODUCT_PRICE_ID => 20, + ProductPriceOccurrenceOverrideDomainObjectAbstract::PRICE => 99.99, + ]) + ->andReturn($expectedOverride); + + $result = $this->handler->handle($dto); + + $this->assertSame($expectedOverride, $result); + } + + public function testHandleUpdatesExistingOverride(): void + { + $this->mockOwnershipChecks(); + + $dto = new UpsertPriceOverrideDTO( + event_id: 1, + event_occurrence_id: 10, + product_price_id: 20, + price: 149.99, + ); + + $existingOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class); + $existingOverride->shouldReceive('getId')->andReturn(5); + + $updatedOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class); + + $this->overrideRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => 10, + ProductPriceOccurrenceOverrideDomainObjectAbstract::PRODUCT_PRICE_ID => 20, + ]) + ->andReturn($existingOverride); + + $this->overrideRepository + ->shouldNotReceive('create'); + + $this->overrideRepository + ->shouldReceive('updateFromArray') + ->once() + ->with(5, [ + ProductPriceOccurrenceOverrideDomainObjectAbstract::PRICE => 149.99, + ]) + ->andReturn($updatedOverride); + + $result = $this->handler->handle($dto); + + $this->assertSame($updatedOverride, $result); + } + + public function testHandlePassesCorrectEventOccurrenceId(): void + { + $occurrenceId = 42; + $this->mockOwnershipChecks(); + + $dto = new UpsertPriceOverrideDTO( + event_id: 1, + event_occurrence_id: $occurrenceId, + product_price_id: 1, + price: 50.00, + ); + + $expectedOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class); + + $this->overrideRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(Mockery::on(function ($arg) use ($occurrenceId) { + return $arg[ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID] === $occurrenceId; + })) + ->andReturn(null); + + $this->overrideRepository + ->shouldReceive('create') + ->once() + ->with(Mockery::on(function ($arg) use ($occurrenceId) { + return $arg[ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID] === $occurrenceId; + })) + ->andReturn($expectedOverride); + + $result = $this->handler->handle($dto); + + $this->assertSame($expectedOverride, $result); + } + + public function testHandlePassesCorrectProductPriceId(): void + { + $priceId = 77; + $this->mockOwnershipChecks(); + + $dto = new UpsertPriceOverrideDTO( + event_id: 1, + event_occurrence_id: 1, + product_price_id: $priceId, + price: 25.00, + ); + + $expectedOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class); + + $this->overrideRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(Mockery::on(function ($arg) use ($priceId) { + return $arg[ProductPriceOccurrenceOverrideDomainObjectAbstract::PRODUCT_PRICE_ID] === $priceId; + })) + ->andReturn(null); + + $this->overrideRepository + ->shouldReceive('create') + ->once() + ->with(Mockery::on(function ($arg) use ($priceId) { + return $arg[ProductPriceOccurrenceOverrideDomainObjectAbstract::PRODUCT_PRICE_ID] === $priceId; + })) + ->andReturn($expectedOverride); + + $result = $this->handler->handle($dto); + + $this->assertSame($expectedOverride, $result); + } + + public function testHandlePassesCorrectPrice(): void + { + $price = 199.50; + $this->mockOwnershipChecks(); + + $dto = new UpsertPriceOverrideDTO( + event_id: 1, + event_occurrence_id: 1, + product_price_id: 2, + price: $price, + ); + + $expectedOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class); + + $this->overrideRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturn(null); + + $this->overrideRepository + ->shouldReceive('create') + ->once() + ->with(Mockery::on(function ($arg) use ($price) { + return $arg[ProductPriceOccurrenceOverrideDomainObjectAbstract::PRICE] === $price; + })) + ->andReturn($expectedOverride); + + $result = $this->handler->handle($dto); + + $this->assertSame($expectedOverride, $result); + } + + public function test_it_throws_when_occurrence_not_found_for_event(): void + { + $this->expectException(ResourceNotFoundException::class); + + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturn(null); + + $dto = new UpsertPriceOverrideDTO( + event_id: 1, + event_occurrence_id: 99, + product_price_id: 20, + price: 49.99, + ); + + $this->handler->handle($dto); + } + + public function test_it_throws_when_product_price_not_found(): void + { + $this->expectException(ResourceNotFoundException::class); + + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class)); + + $this->productPriceRepository + ->shouldReceive('findFirst') + ->once() + ->andReturn(null); + + $dto = new UpsertPriceOverrideDTO( + event_id: 1, + event_occurrence_id: 10, + product_price_id: 99, + price: 49.99, + ); + + $this->handler->handle($dto); + } + + public function test_it_throws_when_product_not_belonging_to_event(): void + { + $this->expectException(ResourceNotFoundException::class); + + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class)); + + $priceMock = Mockery::mock(ProductPriceDomainObject::class); + $priceMock->shouldReceive('getProductId')->andReturn(5); + $this->productPriceRepository + ->shouldReceive('findFirst') + ->once() + ->andReturn($priceMock); + + $this->productRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturn(null); + + $dto = new UpsertPriceOverrideDTO( + event_id: 1, + event_occurrence_id: 10, + product_price_id: 20, + price: 49.99, + ); + + $this->handler->handle($dto); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/ReactivateOccurrenceHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/ReactivateOccurrenceHandlerTest.php new file mode 100644 index 0000000000..f857357ad7 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/ReactivateOccurrenceHandlerTest.php @@ -0,0 +1,125 @@ +occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->exclusionService = Mockery::mock(RecurrenceRuleExclusionService::class); + $this->databaseManager = Mockery::mock(DatabaseManager::class); + + $this->databaseManager->shouldReceive('transaction') + ->andReturnUsing(fn ($callback) => $callback()); + + $this->handler = new ReactivateOccurrenceHandler( + $this->occurrenceRepository, + $this->exclusionService, + $this->databaseManager, + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function test_reactivates_cancelled_occurrence_and_only_touches_status(): void + { + $eventId = 1; + $occurrenceId = 10; + + $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); + $occurrence->shouldReceive('getEventId')->andReturn($eventId); + $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::CANCELLED->name); + $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-01 10:00:00'); + + $this->occurrenceRepository + ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn($occurrence); + + $this->occurrenceRepository + ->shouldReceive('updateFromArray') + ->once() + ->with($occurrenceId, [ + EventOccurrenceDomainObjectAbstract::STATUS => EventOccurrenceStatus::ACTIVE->name, + ]) + ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class)); + + $this->exclusionService + ->shouldReceive('removeExclusion') + ->once() + ->with($eventId, '2026-06-01 10:00:00'); + + $result = $this->handler->handle($eventId, $occurrenceId); + + $this->assertNotNull($result); + } + + public function test_rejects_reactivation_of_non_cancelled_occurrence(): void + { + $eventId = 1; + $occurrenceId = 10; + + $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); + $occurrence->shouldReceive('getEventId')->andReturn($eventId); + $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name); + + $this->occurrenceRepository + ->shouldReceive('findByIdLocked')->once()->andReturn($occurrence); + + $this->occurrenceRepository->shouldNotReceive('updateFromArray'); + $this->exclusionService->shouldNotReceive('removeExclusion'); + + $this->expectException(ValidationException::class); + + $this->handler->handle($eventId, $occurrenceId); + } + + public function test_throws_when_occurrence_not_found_for_event(): void + { + $this->occurrenceRepository + ->shouldReceive('findByIdLocked')->once()->andReturn(null); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle(eventId: 1, occurrenceId: 99); + } + + public function test_throws_when_occurrence_belongs_to_different_event(): void + { + $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); + $occurrence->shouldReceive('getEventId')->andReturn(2); + + $this->occurrenceRepository + ->shouldReceive('findByIdLocked')->once()->andReturn($occurrence); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle(eventId: 1, occurrenceId: 10); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandlerTest.php new file mode 100644 index 0000000000..b4d8d1f75b --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandlerTest.php @@ -0,0 +1,465 @@ +occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->databaseManager = Mockery::mock(DatabaseManager::class); + + $this->databaseManager->shouldReceive('transaction') + ->andReturnUsing(fn ($callback) => $callback()); + + $this->handler = new UpdateEventOccurrenceHandler( + $this->occurrenceRepository, + $this->databaseManager, + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + /** + * Convenience factory for the "existing occurrence" mock with the fields the + * override-detection logic reads. + */ + private function existingOccurrence( + int $id = 10, + string $startDate = '2026-06-01 10:00:00', + ?string $endDate = '2026-06-01 18:00:00', + ?int $capacity = 100, + bool $isOverridden = false, + string $status = EventOccurrenceStatus::ACTIVE->name, + int $usedCapacity = 0, + ): MockInterface { + $occ = Mockery::mock(EventOccurrenceDomainObject::class); + $occ->shouldReceive('getId')->andReturn($id); + $occ->shouldReceive('getStartDate')->andReturn($startDate); + $occ->shouldReceive('getEndDate')->andReturn($endDate); + $occ->shouldReceive('getCapacity')->andReturn($capacity); + $occ->shouldReceive('getIsOverridden')->andReturn($isOverridden); + $occ->shouldReceive('getStatus')->andReturn($status); + // SOLD_OUT/ACTIVE reconciliation in the handler reads used_capacity to + // decide whether the new ceiling has headroom. + $occ->shouldReceive('getUsedCapacity')->andReturn($usedCapacity); + + return $occ; + } + + public function test_flags_as_overridden_when_start_date_changes(): void + { + $occurrenceId = 10; + $eventId = 1; + + $existing = $this->existingOccurrence(id: $occurrenceId); + + $dto = new UpsertEventOccurrenceDTO( + event_id: $eventId, + start_date: '2026-06-02 10:00:00', // moved by a day + end_date: '2026-06-01 18:00:00', + capacity: 100, + label: 'Same label', + ); + + $this->occurrenceRepository + ->shouldReceive('findFirstWhere')->once()->andReturn($existing); + + $this->occurrenceRepository + ->shouldReceive('updateFromArray') + ->once() + ->with($occurrenceId, Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === true)) + ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class)); + + $result = $this->handler->handle($occurrenceId, $dto); + $this->assertNotNull($result); + } + + public function test_flags_as_overridden_when_end_date_changes(): void + { + $occurrenceId = 10; + $eventId = 1; + + $existing = $this->existingOccurrence(id: $occurrenceId); + + $dto = new UpsertEventOccurrenceDTO( + event_id: $eventId, + start_date: '2026-06-01 10:00:00', + end_date: '2026-06-01 20:00:00', // extended by 2 hours + capacity: 100, + label: null, + ); + + $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing); + + $this->occurrenceRepository + ->shouldReceive('updateFromArray') + ->once() + ->with($occurrenceId, Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === true)) + ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class)); + + $result = $this->handler->handle($occurrenceId, $dto); + $this->assertNotNull($result); + } + + public function test_flags_as_overridden_when_capacity_changes(): void + { + $occurrenceId = 10; + $eventId = 1; + + $existing = $this->existingOccurrence(id: $occurrenceId); + + $dto = new UpsertEventOccurrenceDTO( + event_id: $eventId, + start_date: '2026-06-01 10:00:00', + end_date: '2026-06-01 18:00:00', + capacity: 200, // changed from 100 + label: null, + ); + + $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing); + + $this->occurrenceRepository + ->shouldReceive('updateFromArray') + ->once() + ->with($occurrenceId, Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === true)) + ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class)); + + $result = $this->handler->handle($occurrenceId, $dto); + $this->assertNotNull($result); + } + + public function test_does_not_flag_as_overridden_for_label_only_change(): void + { + // A label-only edit shouldn't pin the occurrence against rule regenerates. + $occurrenceId = 10; + $eventId = 1; + + $existing = $this->existingOccurrence(id: $occurrenceId, isOverridden: false); + + $dto = new UpsertEventOccurrenceDTO( + event_id: $eventId, + start_date: '2026-06-01 10:00:00', + end_date: '2026-06-01 18:00:00', + capacity: 100, + label: 'Brand new label', + ); + + $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing); + + $this->occurrenceRepository + ->shouldReceive('updateFromArray') + ->once() + ->with($occurrenceId, Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === false)) + ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class)); + + $result = $this->handler->handle($occurrenceId, $dto); + $this->assertNotNull($result); + } + + public function test_preserves_override_flag_when_already_overridden(): void + { + // Once overridden, stays overridden regardless of which fields change now — + // we don't un-override just because the user happened to save with + // rule-aligned values. + $occurrenceId = 10; + $eventId = 1; + + $existing = $this->existingOccurrence(id: $occurrenceId, isOverridden: true); + + $dto = new UpsertEventOccurrenceDTO( + event_id: $eventId, + start_date: '2026-06-01 10:00:00', + end_date: '2026-06-01 18:00:00', + capacity: 100, + label: 'Label change only', + ); + + $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing); + + $this->occurrenceRepository + ->shouldReceive('updateFromArray') + ->once() + ->with($occurrenceId, Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === true)) + ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class)); + + $result = $this->handler->handle($occurrenceId, $dto); + $this->assertNotNull($result); + } + + public function test_does_not_flag_as_overridden_when_dates_are_same_instant_different_format(): void + { + // DateHelper::convertToUTC (used by UpdateEventOccurrenceAction) returns + // "Mon Jun 15 2026 10:00:00 GMT+0000" style while DB-hydrated getStartDate() + // returns SQL format. Plain strict string equality would mark these as + // different even though they represent the same instant — regression test. + $occurrenceId = 10; + $eventId = 1; + + $existing = $this->existingOccurrence( + id: $occurrenceId, + startDate: '2026-06-01 10:00:00', + endDate: '2026-06-01 18:00:00', + isOverridden: false, + ); + + $dto = new UpsertEventOccurrenceDTO( + event_id: $eventId, + start_date: 'Mon Jun 01 2026 10:00:00 GMT+0000', + end_date: 'Mon Jun 01 2026 18:00:00 GMT+0000', + capacity: 100, + label: 'New label', + ); + + $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing); + + $this->occurrenceRepository + ->shouldReceive('updateFromArray') + ->once() + ->with($occurrenceId, Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === false)) + ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class)); + + $result = $this->handler->handle($occurrenceId, $dto); + $this->assertNotNull($result); + } + + public function test_handle_does_not_write_status_when_capacity_unchanged_and_status_already_correct(): void + { + // CANCELLED is owned by the dedicated cancel/reactivate handlers. + // For capacity-derived states (ACTIVE / SOLD_OUT) the handler only + // writes STATUS when the new ceiling actually changes which side of + // capacity the occurrence sits on. A label-only edit on an ACTIVE + // occurrence with no capacity change must leave STATUS untouched. + $occurrenceId = 10; + $eventId = 1; + + $existing = $this->existingOccurrence( + id: $occurrenceId, + capacity: 100, + status: EventOccurrenceStatus::ACTIVE->name, + usedCapacity: 10, + ); + + $dto = new UpsertEventOccurrenceDTO( + event_id: $eventId, + start_date: '2026-06-01 10:00:00', + end_date: '2026-06-01 18:00:00', + capacity: 100, + label: 'New label', + ); + + $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing); + + $this->occurrenceRepository + ->shouldReceive('updateFromArray') + ->once() + ->with( + $occurrenceId, + Mockery::on(fn (array $attrs) => ! array_key_exists(EventOccurrenceDomainObjectAbstract::STATUS, $attrs)), + ) + ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class)); + + $result = $this->handler->handle($occurrenceId, $dto); + $this->assertNotNull($result); + } + + public function test_handle_reactivates_sold_out_occurrence_when_capacity_increases_above_used(): void + { + // ProductQuantityUpdateService::increaseOccurrenceUsedCapacity flips + // ACTIVE → SOLD_OUT when usage crosses the ceiling. The reverse path + // only runs from decreaseOccurrenceUsedCapacity, so a capacity edit + // that raises the ceiling above current usage is the only place the + // generic update handler can re-open a sold-out date. + $occurrenceId = 10; + $eventId = 1; + + $existing = $this->existingOccurrence( + id: $occurrenceId, + capacity: 50, + status: EventOccurrenceStatus::SOLD_OUT->name, + usedCapacity: 50, + ); + + $dto = new UpsertEventOccurrenceDTO( + event_id: $eventId, + start_date: '2026-06-01 10:00:00', + end_date: '2026-06-01 18:00:00', + capacity: 100, // raised — sold-out should clear + ); + + $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing); + + $this->occurrenceRepository + ->shouldReceive('updateFromArray') + ->once() + ->with( + $occurrenceId, + Mockery::on(fn (array $attrs) => ($attrs[EventOccurrenceDomainObjectAbstract::STATUS] ?? null) === EventOccurrenceStatus::ACTIVE->name + && $attrs[EventOccurrenceDomainObjectAbstract::CAPACITY] === 100), + ) + ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class)); + + $result = $this->handler->handle($occurrenceId, $dto); + $this->assertNotNull($result); + } + + public function test_handle_reactivates_sold_out_occurrence_when_capacity_cleared_to_unlimited(): void + { + // Unlimited capacity (null) can never be sold out. + $occurrenceId = 10; + $eventId = 1; + + $existing = $this->existingOccurrence( + id: $occurrenceId, + capacity: 50, + status: EventOccurrenceStatus::SOLD_OUT->name, + usedCapacity: 50, + ); + + $dto = new UpsertEventOccurrenceDTO( + event_id: $eventId, + start_date: '2026-06-01 10:00:00', + end_date: '2026-06-01 18:00:00', + capacity: null, + ); + + $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing); + + $this->occurrenceRepository + ->shouldReceive('updateFromArray') + ->once() + ->with( + $occurrenceId, + Mockery::on(fn (array $attrs) => ($attrs[EventOccurrenceDomainObjectAbstract::STATUS] ?? null) === EventOccurrenceStatus::ACTIVE->name + && $attrs[EventOccurrenceDomainObjectAbstract::CAPACITY] === null), + ) + ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class)); + + $result = $this->handler->handle($occurrenceId, $dto); + $this->assertNotNull($result); + } + + public function test_handle_marks_active_occurrence_sold_out_when_capacity_drops_below_used(): void + { + $occurrenceId = 10; + $eventId = 1; + + $existing = $this->existingOccurrence( + id: $occurrenceId, + capacity: 100, + status: EventOccurrenceStatus::ACTIVE->name, + usedCapacity: 80, + ); + + $dto = new UpsertEventOccurrenceDTO( + event_id: $eventId, + start_date: '2026-06-01 10:00:00', + end_date: '2026-06-01 18:00:00', + capacity: 50, // below current usage + ); + + $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing); + + $this->occurrenceRepository + ->shouldReceive('updateFromArray') + ->once() + ->with( + $occurrenceId, + Mockery::on(fn (array $attrs) => ($attrs[EventOccurrenceDomainObjectAbstract::STATUS] ?? null) === EventOccurrenceStatus::SOLD_OUT->name + && $attrs[EventOccurrenceDomainObjectAbstract::CAPACITY] === 50), + ) + ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class)); + + $result = $this->handler->handle($occurrenceId, $dto); + $this->assertNotNull($result); + } + + public function test_handle_does_not_write_status_for_cancelled_occurrence_even_when_capacity_changes(): void + { + // CANCELLED is the load-bearing exception — its lifecycle is owned by + // the cancel/reactivate handlers. A capacity edit on a cancelled date + // (rare, but possible via direct API call) must not silently flip it + // back to ACTIVE. + $occurrenceId = 10; + $eventId = 1; + + $existing = $this->existingOccurrence( + id: $occurrenceId, + capacity: 100, + status: EventOccurrenceStatus::CANCELLED->name, + usedCapacity: 0, + ); + + $dto = new UpsertEventOccurrenceDTO( + event_id: $eventId, + start_date: '2026-06-01 10:00:00', + end_date: '2026-06-01 18:00:00', + capacity: 200, + ); + + $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing); + + $this->occurrenceRepository + ->shouldReceive('updateFromArray') + ->once() + ->with( + $occurrenceId, + Mockery::on(fn (array $attrs) => ! array_key_exists(EventOccurrenceDomainObjectAbstract::STATUS, $attrs)), + ) + ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class)); + + $result = $this->handler->handle($occurrenceId, $dto); + $this->assertNotNull($result); + } + + public function test_handle_throws_exception_when_occurrence_not_found(): void + { + $occurrenceId = 999; + $eventId = 1; + + $dto = new UpsertEventOccurrenceDTO( + event_id: $eventId, + start_date: '2026-06-01 10:00:00', + ); + + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + EventOccurrenceDomainObjectAbstract::ID => $occurrenceId, + EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId, + ]) + ->andReturn(null); + + $this->occurrenceRepository->shouldNotReceive('updateFromArray'); + + $this->expectException(ResourceNotFoundException::class); + + $result = $this->handler->handle($occurrenceId, $dto); + $this->assertNotNull($result); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandlerTest.php new file mode 100644 index 0000000000..4112178c89 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandlerTest.php @@ -0,0 +1,169 @@ +visibilityRepository = Mockery::mock(ProductOccurrenceVisibilityRepositoryInterface::class); + $this->productRepository = Mockery::mock(ProductRepositoryInterface::class); + $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->databaseManager = Mockery::mock(DatabaseManager::class); + + $this->databaseManager->shouldReceive('transaction') + ->andReturnUsing(fn($callback) => $callback()); + + $this->handler = new UpdateProductVisibilityHandler( + $this->visibilityRepository, + $this->productRepository, + $this->occurrenceRepository, + $this->databaseManager, + ); + } + + private function makeProductCollection(array $ids): Collection + { + return collect(array_map(function ($id) { + return new class($id) { + public function __construct(public readonly int $id) {} + public function offsetGet($key) { return $this->$key; } + public function offsetExists($key): bool { return isset($this->$key); } + }; + }, $ids)); + } + + public function testHandleCreatesVisibilityRecordsForSelectedProducts(): void + { + $dto = new UpdateProductVisibilityDTO( + event_id: 1, + event_occurrence_id: 10, + product_ids: [5], + ); + + $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); + + $this->occurrenceRepository->shouldReceive('findFirstWhere') + ->once() + ->with([ + EventOccurrenceDomainObjectAbstract::ID => 10, + EventOccurrenceDomainObjectAbstract::EVENT_ID => 1, + ]) + ->andReturn($occurrence); + + $this->visibilityRepository->shouldReceive('deleteWhere')->once(); + + $this->productRepository->shouldReceive('findWhere') + ->once() + ->with([ProductDomainObjectAbstract::EVENT_ID => 1]) + ->andReturn($this->makeProductCollection([5, 10])); + + $this->visibilityRepository->shouldReceive('create') + ->once() + ->with([ + ProductOccurrenceVisibilityDomainObjectAbstract::EVENT_OCCURRENCE_ID => 10, + ProductOccurrenceVisibilityDomainObjectAbstract::PRODUCT_ID => 5, + ]); + + $visibilityRecords = collect([Mockery::mock(ProductOccurrenceVisibilityDomainObject::class)]); + $this->visibilityRepository->shouldReceive('findWhere') + ->once() + ->with([ProductOccurrenceVisibilityDomainObjectAbstract::EVENT_OCCURRENCE_ID => 10]) + ->andReturn($visibilityRecords); + + $result = $this->handler->handle($dto); + + $this->assertCount(1, $result); + } + + public function testHandleReturnsEmptyWhenAllProductsSelected(): void + { + $dto = new UpdateProductVisibilityDTO( + event_id: 1, + event_occurrence_id: 10, + product_ids: [5, 10], + ); + + $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); + + $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($occurrence); + $this->visibilityRepository->shouldReceive('deleteWhere')->once(); + + $this->productRepository->shouldReceive('findWhere')->once()->andReturn($this->makeProductCollection([5, 10])); + + $this->visibilityRepository->shouldNotReceive('create'); + + $result = $this->handler->handle($dto); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertEmpty($result); + } + + public function testHandleThrowsWhenOccurrenceNotFound(): void + { + $dto = new UpdateProductVisibilityDTO( + event_id: 1, + event_occurrence_id: 999, + product_ids: [5], + ); + + $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn(null); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle($dto); + } + + public function testHandleThrowsWhenProductIdDoesNotBelongToEvent(): void + { + $dto = new UpdateProductVisibilityDTO( + event_id: 1, + event_occurrence_id: 10, + product_ids: [5, 999], + ); + + $occurrence = Mockery::mock(EventOccurrenceDomainObject::class); + + $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($occurrence); + $this->visibilityRepository->shouldReceive('deleteWhere')->once(); + + $this->productRepository->shouldReceive('findWhere')->once()->andReturn($this->makeProductCollection([5])); + + $this->visibilityRepository->shouldNotReceive('create'); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle($dto); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandlerTest.php index 5dfa38f978..7324b324d8 100644 --- a/backend/tests/Unit/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandlerTest.php @@ -2,11 +2,9 @@ namespace Tests\Unit\Services\Application\Handlers\EventSettings; -use Brick\Money\Currency; -use HiEvents\DomainObjects\AccountConfigurationDomainObject; -use HiEvents\DomainObjects\AccountDomainObject; use HiEvents\DomainObjects\EventDomainObject; -use HiEvents\Repository\Interfaces\AccountRepositoryInterface; +use HiEvents\DomainObjects\OrganizerConfigurationDomainObject; +use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Services\Application\Handlers\EventSettings\DTO\GetPlatformFeePreviewDTO; use HiEvents\Services\Application\Handlers\EventSettings\GetPlatformFeePreviewHandler; @@ -20,7 +18,6 @@ class GetPlatformFeePreviewHandlerTest extends TestCase { use MockeryPHPUnitIntegration; - private AccountRepositoryInterface $accountRepository; private EventRepositoryInterface $eventRepository; private CurrencyConversionClientInterface $currencyConversionClient; private GetPlatformFeePreviewHandler $handler; @@ -29,128 +26,89 @@ protected function setUp(): void { parent::setUp(); - $this->accountRepository = Mockery::mock(AccountRepositoryInterface::class); $this->eventRepository = Mockery::mock(EventRepositoryInterface::class); $this->currencyConversionClient = Mockery::mock(CurrencyConversionClientInterface::class); $this->handler = new GetPlatformFeePreviewHandler( - $this->accountRepository, $this->eventRepository, - $this->currencyConversionClient + $this->currencyConversionClient, ); } - public function testPreviewWithSameCurrency(): void + private function mockEventWithConfiguration(string $eventCurrency, ?OrganizerConfigurationDomainObject $configuration): EventDomainObject { - $eventId = 1; - $price = 100.0; + $organizer = Mockery::mock(OrganizerDomainObject::class); + $organizer->shouldReceive('getOrganizerConfiguration')->andReturn($configuration); $event = Mockery::mock(EventDomainObject::class); - $event->shouldReceive('getCurrency')->andReturn('USD'); + $event->shouldReceive('getCurrency')->andReturn($eventCurrency); + $event->shouldReceive('getOrganizer')->andReturn($organizer); + + return $event; + } - $configuration = Mockery::mock(AccountConfigurationDomainObject::class); + public function testPreviewWithSameCurrency(): void + { + $configuration = Mockery::mock(OrganizerConfigurationDomainObject::class); $configuration->shouldReceive('getApplicationFeeCurrency')->andReturn('USD'); $configuration->shouldReceive('getFixedApplicationFee')->andReturn(1.0); $configuration->shouldReceive('getPercentageApplicationFee')->andReturn(10.0); - $account = Mockery::mock(AccountDomainObject::class); - $account->shouldReceive('getConfiguration')->andReturn($configuration); - + $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->eventRepository->shouldReceive('findById') - ->with($eventId) - ->andReturn($event); + ->with(1) + ->andReturn($this->mockEventWithConfiguration('USD', $configuration)); - $this->accountRepository->shouldReceive('loadRelation') - ->andReturnSelf(); - $this->accountRepository->shouldReceive('findByEventId') - ->with($eventId) - ->andReturn($account); - - $dto = new GetPlatformFeePreviewDTO(eventId: $eventId, price: $price); - $result = $this->handler->handle($dto); + $result = $this->handler->handle(new GetPlatformFeePreviewDTO(eventId: 1, price: 100.0)); $this->assertEquals('USD', $result->eventCurrency); $this->assertEquals('USD', $result->feeCurrency); $this->assertEquals(1.0, $result->fixedFeeOriginal); $this->assertEquals(1.0, $result->fixedFeeConverted); $this->assertEquals(10.0, $result->percentageFee); - $this->assertEquals(100.0, $result->samplePrice); - // Gross-up: (1 + 100*0.1) / (1 - 0.1) = 11 / 0.9 = 12.22 $this->assertEquals(12.22, $result->platformFee); $this->assertEquals(112.22, $result->total); } public function testPreviewWithCurrencyConversion(): void { - $eventId = 1; - $price = 100.0; - - $event = Mockery::mock(EventDomainObject::class); - $event->shouldReceive('getCurrency')->andReturn('EUR'); - - $configuration = Mockery::mock(AccountConfigurationDomainObject::class); + $configuration = Mockery::mock(OrganizerConfigurationDomainObject::class); $configuration->shouldReceive('getApplicationFeeCurrency')->andReturn('GBP'); $configuration->shouldReceive('getFixedApplicationFee')->andReturn(1.0); $configuration->shouldReceive('getPercentageApplicationFee')->andReturn(10.0); - $account = Mockery::mock(AccountDomainObject::class); - $account->shouldReceive('getConfiguration')->andReturn($configuration); - + $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->eventRepository->shouldReceive('findById') - ->with($eventId) - ->andReturn($event); + ->with(1) + ->andReturn($this->mockEventWithConfiguration('EUR', $configuration)); - $this->accountRepository->shouldReceive('loadRelation') - ->andReturnSelf(); - $this->accountRepository->shouldReceive('findByEventId') - ->with($eventId) - ->andReturn($account); - - // Mock GBP to EUR conversion: £1 = €1.15 $this->currencyConversionClient->shouldReceive('convert') ->with( Mockery::on(fn($c) => $c->getCurrencyCode() === 'GBP'), Mockery::on(fn($c) => $c->getCurrencyCode() === 'EUR'), - 1.0 + 1.0, ) ->andReturn(MoneyValue::fromFloat(1.15, 'EUR')); - $dto = new GetPlatformFeePreviewDTO(eventId: $eventId, price: $price); - $result = $this->handler->handle($dto); + $result = $this->handler->handle(new GetPlatformFeePreviewDTO(eventId: 1, price: 100.0)); $this->assertEquals('EUR', $result->eventCurrency); $this->assertEquals('GBP', $result->feeCurrency); $this->assertEquals(1.0, $result->fixedFeeOriginal); $this->assertEquals(1.15, $result->fixedFeeConverted); $this->assertEquals(10.0, $result->percentageFee); - // Gross-up: (1.15 + 100*0.1) / (1 - 0.1) = 11.15 / 0.9 = 12.39 $this->assertEquals(12.39, $result->platformFee); $this->assertEquals(112.39, $result->total); } public function testPreviewWithNoConfiguration(): void { - $eventId = 1; - $price = 100.0; - - $event = Mockery::mock(EventDomainObject::class); - $event->shouldReceive('getCurrency')->andReturn('USD'); - - $account = Mockery::mock(AccountDomainObject::class); - $account->shouldReceive('getConfiguration')->andReturn(null); - + $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->eventRepository->shouldReceive('findById') - ->with($eventId) - ->andReturn($event); - - $this->accountRepository->shouldReceive('loadRelation') - ->andReturnSelf(); - $this->accountRepository->shouldReceive('findByEventId') - ->with($eventId) - ->andReturn($account); + ->with(1) + ->andReturn($this->mockEventWithConfiguration('USD', null)); - $dto = new GetPlatformFeePreviewDTO(eventId: $eventId, price: $price); - $result = $this->handler->handle($dto); + $result = $this->handler->handle(new GetPlatformFeePreviewDTO(eventId: 1, price: 100.0)); $this->assertEquals('USD', $result->eventCurrency); $this->assertNull($result->feeCurrency); @@ -161,34 +119,18 @@ public function testPreviewWithNoConfiguration(): void public function testPreviewWithZeroPercentageFee(): void { - $eventId = 1; - $price = 100.0; - - $event = Mockery::mock(EventDomainObject::class); - $event->shouldReceive('getCurrency')->andReturn('USD'); - - $configuration = Mockery::mock(AccountConfigurationDomainObject::class); + $configuration = Mockery::mock(OrganizerConfigurationDomainObject::class); $configuration->shouldReceive('getApplicationFeeCurrency')->andReturn('USD'); $configuration->shouldReceive('getFixedApplicationFee')->andReturn(0.50); $configuration->shouldReceive('getPercentageApplicationFee')->andReturn(0.0); - $account = Mockery::mock(AccountDomainObject::class); - $account->shouldReceive('getConfiguration')->andReturn($configuration); - + $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->eventRepository->shouldReceive('findById') - ->with($eventId) - ->andReturn($event); - - $this->accountRepository->shouldReceive('loadRelation') - ->andReturnSelf(); - $this->accountRepository->shouldReceive('findByEventId') - ->with($eventId) - ->andReturn($account); + ->with(1) + ->andReturn($this->mockEventWithConfiguration('USD', $configuration)); - $dto = new GetPlatformFeePreviewDTO(eventId: $eventId, price: $price); - $result = $this->handler->handle($dto); + $result = $this->handler->handle(new GetPlatformFeePreviewDTO(eventId: 1, price: 100.0)); - // With 0% percentage, just the fixed fee $this->assertEquals(0.50, $result->platformFee); $this->assertEquals(100.50, $result->total); } diff --git a/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerTest.php index 4f9cccdedf..0270803692 100644 --- a/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerTest.php @@ -30,13 +30,21 @@ class SendMessageHandlerTest extends TestCase { private OrderRepositoryInterface $orderRepository; + private AttendeeRepositoryInterface $attendeeRepository; + private ProductRepositoryInterface $productRepository; + private MessageRepositoryInterface $messageRepository; + private AccountRepositoryInterface $accountRepository; + private HtmlPurifierService $purifier; + private Repository $config; + private MessagingEligibilityService $eligibilityService; + private EventRepositoryInterface $eventRepository; private SendMessageHandler $handler; @@ -68,7 +76,7 @@ protected function setUp(): void ); } - public function testThrowsIfAccountNotVerified(): void + public function test_throws_if_account_not_verified(): void { $dto = new SendMessageDTO( account_id: 1, @@ -95,7 +103,7 @@ public function testThrowsIfAccountNotVerified(): void $this->handler->handle($dto); } - public function testThrowsIfSaasModeEnabledAndNotManuallyVerified(): void + public function test_throws_if_saas_mode_enabled_and_not_manually_verified(): void { $dto = new SendMessageDTO( account_id: 1, @@ -125,7 +133,7 @@ public function testThrowsIfSaasModeEnabledAndNotManuallyVerified(): void $this->handler->handle($dto); } - public function testHandleCreatesMessageAndDispatchesJob(): void + public function test_handle_creates_message_and_dispatches_job(): void { $dto = new SendMessageDTO( account_id: 1, @@ -159,13 +167,13 @@ public function testHandleCreatesMessageAndDispatchesJob(): void $this->purifier->shouldReceive('purify')->with('

Test

')->andReturn('

Test

'); - $attendee = new AttendeeDomainObject(); + $attendee = new AttendeeDomainObject; $attendee->setId(10); - $product = new ProductDomainObject(); + $product = new ProductDomainObject; $product->setId(20); - $order = new OrderDomainObject(); + $order = new OrderDomainObject; $order->setId(5); $this->attendeeRepository->shouldReceive('findWhereIn')->andReturn(collect([$attendee])); @@ -189,4 +197,75 @@ public function testHandleCreatesMessageAndDispatchesJob(): void Bus::assertDispatched(SendMessagesJob::class); } + + public function test_handle_estimates_recipients_for_multi_occurrence_targeting(): void + { + // event_occurrence_ids (array) should produce an IN-style query against + // attendees, and the dispatched job DTO must carry the array forward. + $dto = new SendMessageDTO( + account_id: 1, + event_id: 101, + subject: 'Hi', + message: '

Body

', + type: MessageTypeEnum::ALL_ATTENDEES, + is_test: false, + send_copy_to_current_user: false, + sent_by_user_id: 99, + event_occurrence_ids: [201, 202, 203], + ); + + $event = m::mock(EventDomainObject::class); + $event->shouldReceive('getTimezone')->andReturn('UTC'); + $this->eventRepository->shouldReceive('findById')->with(101)->andReturn($event); + + $account = m::mock(AccountDomainObject::class); + $account->shouldReceive('getAccountVerifiedAt')->andReturn(Carbon::now()); + $account->shouldReceive('getIsManuallyVerified')->andReturn(true); + $this->accountRepository->shouldReceive('findById')->with(1)->andReturn($account); + + $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturn(false); + $this->eligibilityService->shouldReceive('checkTierLimits')->andReturn(null); + $this->eligibilityService->shouldReceive('checkEligibility')->andReturn(null); + $this->purifier->shouldReceive('purify')->andReturn('

Body

'); + + // Assert the estimate path uses whereIn against the array rather than + // an equality check on a single id. + $this->attendeeRepository + ->shouldReceive('countWhere') + ->once() + ->with(m::on(fn (array $where) => isset($where[0]) + && $where[0][0] === 'event_occurrence_id' + && $where[0][1] === 'in' + && $where[0][2] === [201, 202, 203])) + ->andReturn(42); + + // Stub the rest of the handler path. + $this->attendeeRepository->shouldReceive('findWhereIn')->andReturn(collect()); + $this->productRepository->shouldReceive('findWhereIn')->andReturn(collect()); + $this->orderRepository->shouldReceive('findFirstWhere')->andReturn(null); + + $message = m::mock(MessageDomainObject::class); + $message->shouldReceive('getId')->andReturn(1); + $message->shouldReceive('getOrderId')->andReturn(null); + $message->shouldReceive('getAttendeeIds')->andReturn([]); + $message->shouldReceive('getProductIds')->andReturn([]); + $this->messageRepository + ->shouldReceive('create') + ->once() + ->with(m::on(function (array $attrs) { + // Array is not stored in the dedicated event_occurrence_id column — + // it's persisted in send_data for audit + job replay. + return $attrs['event_occurrence_id'] === null + && ($attrs['send_data']['event_occurrence_ids'] ?? null) === [201, 202, 203]; + })) + ->andReturn($message); + + Bus::fake(); + $this->handler->handle($dto); + + Bus::assertDispatched(SendMessagesJob::class, function (SendMessagesJob $job) { + return $job->messageData->event_occurrence_ids === [201, 202, 203] + && $job->messageData->event_occurrence_id === null; + }); + } } diff --git a/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php index 4d820ff70c..19524202d7 100644 --- a/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php @@ -6,13 +6,16 @@ use Exception; use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; +use HiEvents\DomainObjects\Status\EventOccurrenceStatus; use HiEvents\DomainObjects\Status\OrderStatus; use HiEvents\Exceptions\ResourceConflictException; use HiEvents\Repository\Interfaces\AffiliateRepositoryInterface; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface; @@ -50,6 +53,7 @@ class CompleteOrderHandlerTest extends TestCase private AffiliateRepositoryInterface|MockInterface $affiliateRepository; private EventSettingsRepositoryInterface $eventSettingsRepository; private CheckoutSessionManagementService|MockInterface $sessionManagementService; + private EventOccurrenceRepositoryInterface|MockInterface $occurrenceRepository; protected function setUp(): void { @@ -69,6 +73,15 @@ protected function setUp(): void $this->eventSettingsRepository = Mockery::mock(EventSettingsRepositoryInterface::class); $this->sessionManagementService = Mockery::mock(CheckoutSessionManagementService::class); $this->sessionManagementService->shouldReceive('verifySession')->andReturn(true)->byDefault(); + $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->occurrenceRepository->shouldReceive('findWhereIn')->andReturn( + collect([ + (new EventOccurrenceDomainObject()) + ->setId(1) + ->setStatus(EventOccurrenceStatus::ACTIVE->name) + ->setStartDate(Carbon::now()->addDay()->toDateTimeString()), + ]) + )->byDefault(); $this->completeOrderHandler = new CompleteOrderHandler( $this->orderRepository, @@ -80,6 +93,7 @@ protected function setUp(): void $this->domainEventDispatcherService, $this->eventSettingsRepository, $this->sessionManagementService, + $this->occurrenceRepository, ); } @@ -274,6 +288,55 @@ public function testExceptionIsThrowWhenAttendeeCountDoesNotMatchOrderItemsCount $this->completeOrderHandler->handle($orderShortId, $orderData); } + public function testHandleThrowsResourceConflictExceptionWhenOccurrenceIsCancelled(): void + { + $this->expectException(ResourceConflictException::class); + $this->expectExceptionMessage('This event date has been cancelled'); + + $orderShortId = 'ABC123'; + $orderData = $this->createMockCompleteOrderDTO(); + $order = $this->createMockOrder(); + + $this->eventSettingsRepository->shouldReceive('findFirstWhere')->andReturn($this->createMockEventSetting()); + $this->orderRepository->shouldReceive('findByShortId')->with($orderShortId)->andReturn($order); + $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); + + $this->occurrenceRepository->shouldReceive('findWhereIn')->andReturn( + collect([(new EventOccurrenceDomainObject())->setId(1)->setStatus(EventOccurrenceStatus::CANCELLED->name)]) + ); + + $this->completeOrderHandler->handle($orderShortId, $orderData); + } + + public function testHandleThrowsResourceConflictExceptionWhenOccurrenceHasEnded(): void + { + // Reservation could have been created before the occurrence ended + // (especially with long reservation windows). Re-checking on completion + // prevents a reserved order from aging into a valid purchase for a + // session that has since passed. + $this->expectException(ResourceConflictException::class); + $this->expectExceptionMessage('This event date has already ended'); + + $orderShortId = 'ABC123'; + $orderData = $this->createMockCompleteOrderDTO(); + $order = $this->createMockOrder(); + + $this->eventSettingsRepository->shouldReceive('findFirstWhere')->andReturn($this->createMockEventSetting()); + $this->orderRepository->shouldReceive('findByShortId')->with($orderShortId)->andReturn($order); + $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); + + $this->occurrenceRepository->shouldReceive('findWhereIn')->andReturn( + collect([ + (new EventOccurrenceDomainObject()) + ->setId(1) + ->setStatus(EventOccurrenceStatus::ACTIVE->name) + ->setStartDate(Carbon::now()->subDay()->toDateTimeString()), + ]) + ); + + $this->completeOrderHandler->handle($orderShortId, $orderData); + } + private function createMockCompleteOrderDTO(): CompleteOrderDTO { $orderDTO = new CompleteOrderOrderDTO( @@ -321,7 +384,8 @@ private function createMockOrderItem(): OrderItemDomainObject|MockInterface ->setQuantity(1) ->setPrice(10) ->setTotalGross(10) - ->setProductPriceId(1); + ->setProductPriceId(1) + ->setEventOccurrenceId(1); } private function createMockProductPrice(): ProductPriceDomainObject|MockInterface diff --git a/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerTest.php index 21a06aeb09..a9b5da5d9d 100644 --- a/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerTest.php @@ -93,7 +93,7 @@ public function testThrowsWhenProductQuantityExceedsAvailability(): void $this->orderManagementService->shouldReceive('deleteExistingOrders'); $this->availabilityService->shouldReceive('getAvailableProductQuantities') - ->with($eventId, true) + ->with($eventId, true, Mockery::any()) ->andReturn(new AvailableProductQuantitiesResponseDTO( productQuantities: collect([ AvailableProductQuantitiesDTO::fromArray([ @@ -149,6 +149,7 @@ private function createOrderDTO(int $productId = 10, int $priceId = 100, int $qu 'products' => collect([ ProductOrderDetailsDTO::fromArray([ 'product_id' => $productId, + 'event_occurrence_id' => 1, 'quantities' => collect([ OrderProductPriceDTO::fromArray([ 'price_id' => $priceId, @@ -186,7 +187,7 @@ private function setupSuccessfulOrderCreation( $this->orderManagementService->shouldReceive('deleteExistingOrders'); $this->availabilityService->shouldReceive('getAvailableProductQuantities') - ->with($eventId, true) + ->with($eventId, true, Mockery::any()) ->andReturn(new AvailableProductQuantitiesResponseDTO( productQuantities: collect([ AvailableProductQuantitiesDTO::fromArray([ diff --git a/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/CopyStripeConnectAccountHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/CopyStripeConnectAccountHandlerTest.php new file mode 100644 index 0000000000..0c8b9ee8f3 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/CopyStripeConnectAccountHandlerTest.php @@ -0,0 +1,189 @@ +organizerRepository = m::mock(OrganizerRepositoryInterface::class); + $this->organizerStripePlatformRepository = m::mock(OrganizerStripePlatformRepositoryInterface::class); + $this->stripeAccountSyncService = m::mock(StripeAccountSyncService::class); + $this->stripeAccountSyncService->shouldReceive('seedVatSettingForOrganizerIfMissing')->byDefault(); + $this->databaseManager = m::mock(DatabaseManager::class); + $this->config = m::mock(Repository::class); + } + + protected function tearDown(): void + { + m::close(); + parent::tearDown(); + } + + public function testCopiesConnectionWhenSaasModeEnabledAndSourceComplete(): void + { + $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturnTrue(); + $this->databaseManager + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(fn(Closure $closure) => $closure()); + + $sourcePlatform = (new OrganizerStripePlatformDomainObject()) + ->setId(11) + ->setOrganizerId(2) + ->setStripeAccountId('acct_source') + ->setStripeConnectAccountType('standard') + ->setStripeConnectPlatform('ca') + ->setStripeSetupCompletedAt('2026-01-01 00:00:00') + ->setStripeAccountDetails(['country' => 'CA']); + + $source = (new OrganizerDomainObject()) + ->setId(2) + ->setAccountId(99) + ->setName('Source'); + $source->setOrganizerStripePlatforms(collect([$sourcePlatform])); + + $target = (new OrganizerDomainObject()) + ->setId(1) + ->setAccountId(99) + ->setName('Target'); + $target->setOrganizerStripePlatforms(collect()); + + $this->organizerRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->organizerRepository + ->shouldReceive('findFirstWhere') + ->with(['id' => 1, 'account_id' => 99]) + ->andReturn($target); + $this->organizerRepository + ->shouldReceive('findFirstWhere') + ->with(['id' => 2, 'account_id' => 99]) + ->andReturn($source); + + $this->organizerStripePlatformRepository + ->shouldReceive('create') + ->once() + ->with(m::on(function (array $attrs) { + return $attrs[OrganizerStripePlatformDomainObjectAbstract::ORGANIZER_ID] === 1 + && $attrs[OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID] === 'acct_source' + && $attrs[OrganizerStripePlatformDomainObjectAbstract::STRIPE_CONNECT_PLATFORM] === 'ca'; + })) + ->andReturn(m::mock(OrganizerStripePlatformDomainObject::class)); + + $handler = $this->makeHandler(); + + $response = $handler->handle(new CopyStripeConnectAccountDTO( + targetOrganizerId: 1, + sourceOrganizerId: 2, + accountId: 99, + )); + + $this->assertSame('acct_source', $response->stripeAccountId); + $this->assertTrue($response->isConnectSetupComplete); + } + + public function testThrowsWhenSaasModeDisabled(): void + { + $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturnFalse(); + + $this->expectException(SaasModeEnabledException::class); + + $this->makeHandler()->handle(new CopyStripeConnectAccountDTO( + targetOrganizerId: 1, + sourceOrganizerId: 2, + accountId: 99, + )); + } + + public function testThrowsWhenSourceHasNoCompletedSetup(): void + { + $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturnTrue(); + $this->databaseManager + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(fn(Closure $closure) => $closure()); + + $source = (new OrganizerDomainObject())->setId(2)->setAccountId(99)->setName('Source'); + $source->setOrganizerStripePlatforms(new Collection()); + + $target = (new OrganizerDomainObject())->setId(1)->setAccountId(99)->setName('Target'); + $target->setOrganizerStripePlatforms(new Collection()); + + $this->organizerRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->organizerRepository + ->shouldReceive('findFirstWhere') + ->with(['id' => 1, 'account_id' => 99]) + ->andReturn($target); + $this->organizerRepository + ->shouldReceive('findFirstWhere') + ->with(['id' => 2, 'account_id' => 99]) + ->andReturn($source); + + $this->expectException(ResourceConflictException::class); + + $this->makeHandler()->handle(new CopyStripeConnectAccountDTO( + targetOrganizerId: 1, + sourceOrganizerId: 2, + accountId: 99, + )); + } + + public function testThrowsWhenTargetOrganizerNotFound(): void + { + $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturnTrue(); + $this->databaseManager + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(fn(Closure $closure) => $closure()); + + $this->organizerRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->organizerRepository + ->shouldReceive('findFirstWhere') + ->with(['id' => 1, 'account_id' => 99]) + ->andReturnNull(); + + $this->expectException(ResourceNotFoundException::class); + + $this->makeHandler()->handle(new CopyStripeConnectAccountDTO( + targetOrganizerId: 1, + sourceOrganizerId: 2, + accountId: 99, + )); + } + + private function makeHandler(): CopyStripeConnectAccountHandler + { + return new CopyStripeConnectAccountHandler( + $this->organizerRepository, + $this->organizerStripePlatformRepository, + $this->stripeAccountSyncService, + $this->databaseManager, + $this->config, + ); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/CreateStripeConnectAccountHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/CreateStripeConnectAccountHandlerTest.php new file mode 100644 index 0000000000..63c91d199f --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/CreateStripeConnectAccountHandlerTest.php @@ -0,0 +1,100 @@ +organizerRepository = m::mock(OrganizerRepositoryInterface::class); + $this->organizerStripePlatformRepository = m::mock(OrganizerStripePlatformRepositoryInterface::class); + $this->databaseManager = m::mock(DatabaseManager::class); + $this->logger = m::mock(LoggerInterface::class); + $this->config = m::mock(Repository::class); + $this->stripeClientFactory = m::mock(StripeClientFactory::class); + $this->stripeConfigurationService = m::mock(StripeConfigurationService::class); + $this->stripeAccountSyncService = m::mock(StripeAccountSyncService::class); + } + + protected function tearDown(): void + { + m::close(); + parent::tearDown(); + } + + public function testThrowsWhenSaasModeDisabled(): void + { + $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturnFalse(); + + $this->expectException(SaasModeEnabledException::class); + + $this->makeHandler()->handle(new CreateStripeConnectAccountDTO( + organizerId: 1, + accountId: 99, + )); + } + + public function testThrowsResourceNotFoundWhenOrganizerMissing(): void + { + $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturnTrue(); + $this->databaseManager + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(fn(Closure $closure) => $closure()); + + $this->organizerRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->organizerRepository + ->shouldReceive('findFirstWhere') + ->with(['id' => 1, 'account_id' => 99]) + ->andReturnNull(); + + $this->expectException(ResourceNotFoundException::class); + + $this->makeHandler()->handle(new CreateStripeConnectAccountDTO( + organizerId: 1, + accountId: 99, + )); + } + + private function makeHandler(): CreateStripeConnectAccountHandler + { + return new CreateStripeConnectAccountHandler( + $this->organizerRepository, + $this->organizerStripePlatformRepository, + $this->databaseManager, + $this->logger, + $this->config, + $this->stripeClientFactory, + $this->stripeConfigurationService, + $this->stripeAccountSyncService, + ); + } +} diff --git a/backend/tests/Unit/Services/Domain/CheckInList/CheckInListDataServiceTest.php b/backend/tests/Unit/Services/Domain/CheckInList/CheckInListDataServiceTest.php new file mode 100644 index 0000000000..e09b5a6fba --- /dev/null +++ b/backend/tests/Unit/Services/Domain/CheckInList/CheckInListDataServiceTest.php @@ -0,0 +1,129 @@ +checkInListRepository = Mockery::mock(CheckInListRepositoryInterface::class); + $this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class); + + $this->service = new CheckInListDataService( + $this->checkInListRepository, + $this->attendeeRepository, + ); + } + + public function testVerifyAttendeeBelongsToCheckInListPassesWhenProductMatches(): void + { + $product = Mockery::mock(ProductDomainObject::class); + $product->shouldReceive('getId')->andReturn(1); + + $checkInList = Mockery::mock(CheckInListDomainObject::class); + $checkInList->shouldReceive('getProducts')->andReturn(new Collection([$product])); + $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(null); + + $attendee = Mockery::mock(AttendeeDomainObject::class); + $attendee->shouldReceive('getProductId')->andReturn(1); + + $this->service->verifyAttendeeBelongsToCheckInList($checkInList, $attendee); + + $this->assertTrue(true); + } + + public function testVerifyPassesAcrossOccurrencesWhenListHasNoOccurrence(): void + { + $product = Mockery::mock(ProductDomainObject::class); + $product->shouldReceive('getId')->andReturn(1); + + $checkInList = Mockery::mock(CheckInListDomainObject::class); + $checkInList->shouldReceive('getProducts')->andReturn(new Collection([$product])); + $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(null); + + $attendee = Mockery::mock(AttendeeDomainObject::class); + $attendee->shouldReceive('getProductId')->andReturn(1); + + $this->service->verifyAttendeeBelongsToCheckInList($checkInList, $attendee); + + $this->assertTrue(true); + } + + public function testVerifyPassesWhenOccurrenceMatches(): void + { + $product = Mockery::mock(ProductDomainObject::class); + $product->shouldReceive('getId')->andReturn(1); + + $checkInList = Mockery::mock(CheckInListDomainObject::class); + $checkInList->shouldReceive('getProducts')->andReturn(new Collection([$product])); + $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(5); + + $attendee = Mockery::mock(AttendeeDomainObject::class); + $attendee->shouldReceive('getProductId')->andReturn(1); + $attendee->shouldReceive('getEventOccurrenceId')->andReturn(5); + + $this->service->verifyAttendeeBelongsToCheckInList($checkInList, $attendee); + + $this->assertTrue(true); + } + + public function testVerifyThrowsWhenOccurrenceMismatch(): void + { + $product = Mockery::mock(ProductDomainObject::class); + $product->shouldReceive('getId')->andReturn(1); + + $checkInList = Mockery::mock(CheckInListDomainObject::class); + $checkInList->shouldReceive('getProducts')->andReturn(new Collection([$product])); + $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(5); + + $attendee = Mockery::mock(AttendeeDomainObject::class); + $attendee->shouldReceive('getProductId')->andReturn(1); + $attendee->shouldReceive('getEventOccurrenceId')->andReturn(10); + $attendee->shouldReceive('getFullName')->andReturn('John Doe'); + + $this->expectException(CannotCheckInException::class); + + $this->service->verifyAttendeeBelongsToCheckInList($checkInList, $attendee); + } + + public function testVerifyThrowsWhenProductMismatch(): void + { + $product = Mockery::mock(ProductDomainObject::class); + $product->shouldReceive('getId')->andReturn(1); + + $checkInList = Mockery::mock(CheckInListDomainObject::class); + $checkInList->shouldReceive('getProducts')->andReturn(new Collection([$product])); + + $attendee = Mockery::mock(AttendeeDomainObject::class); + $attendee->shouldReceive('getProductId')->andReturn(99); + $attendee->shouldReceive('getFullName')->andReturn('Jane Doe'); + + $this->expectException(CannotCheckInException::class); + + $this->service->verifyAttendeeBelongsToCheckInList($checkInList, $attendee); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php b/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php index 37ff777ca2..94ee72dd9b 100644 --- a/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php +++ b/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php @@ -155,6 +155,51 @@ public function test_whitelists_only_allowed_tokens_for_attendee_ticket(): void $this->assertArrayHasKey('title', $context['event']); } + public function test_occurrence_dates_override_event_dates(): void + { + $order = $this->createMockOrder(); + $event = $this->createMockEvent(); + $organizer = $this->createMockOrganizer(); + $eventSettings = $this->createMockEventSettings(); + $occurrence = $this->createMockOccurrence(); + + $context = $this->contextBuilder->buildOrderConfirmationContext( + $order, $event, $organizer, $eventSettings, $occurrence + ); + + $this->assertArrayHasKey('occurrence', $context); + $this->assertNotEmpty($context['occurrence']['start_date']); + $this->assertNotEmpty($context['occurrence']['start_time']); + $this->assertNotEmpty($context['occurrence']['end_date']); + $this->assertNotEmpty($context['occurrence']['end_time']); + $this->assertEquals('Afternoon Show', $context['occurrence']['label']); + $this->assertStringContainsString('Afternoon Show', $context['event']['title']); + } + + public function test_occurrence_tokens_empty_when_no_occurrence(): void + { + $order = $this->createMockOrder(); + $event = $this->createMockEvent(); + $organizer = $this->createMockOrganizer(); + $eventSettings = $this->createMockEventSettings(); + + $context = $this->contextBuilder->buildOrderConfirmationContext( + $order, $event, $organizer, $eventSettings + ); + + $this->assertArrayHasKey('occurrence', $context); + $this->assertEquals('', $context['occurrence']['label']); + } + + private function createMockOccurrence(): Mockery\MockInterface + { + return Mockery::mock(\HiEvents\DomainObjects\EventOccurrenceDomainObject::class, [ + 'getStartDate' => '2024-07-20 14:00:00', + 'getEndDate' => '2024-07-20 18:00:00', + 'getLabel' => 'Afternoon Show', + ]); + } + private function createMockOrder(): OrderDomainObject { $orderItem = Mockery::mock(OrderItemDomainObject::class, [ diff --git a/backend/tests/Unit/Services/Domain/Event/CreateEventServiceTest.php b/backend/tests/Unit/Services/Domain/Event/CreateEventServiceTest.php index f05c7ae6f1..091c975fa7 100644 --- a/backend/tests/Unit/Services/Domain/Event/CreateEventServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Event/CreateEventServiceTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Services\Domain\Event; +use HiEvents\DomainObjects\Enums\AttendeeDetailsCollectionMethod; use HiEvents\DomainObjects\Enums\HomepageBackgroundType; use HiEvents\DomainObjects\Enums\ImageType; use HiEvents\DomainObjects\EventDomainObject; @@ -9,15 +10,16 @@ use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\OrganizerSettingDomainObject; use HiEvents\Exceptions\OrganizerNotFoundException; +use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface; use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface; +use HiEvents\Repository\Interfaces\ImageRepositoryInterface; use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; use HiEvents\Services\Domain\Event\CreateEventService; use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService; -use Illuminate\Database\DatabaseManager; -use HiEvents\Repository\Interfaces\ImageRepositoryInterface; use Illuminate\Config\Repository; +use Illuminate\Database\DatabaseManager; use Illuminate\Filesystem\FilesystemManager; use Mockery; use Tests\TestCase; @@ -25,16 +27,29 @@ class CreateEventServiceTest extends TestCase { private CreateEventService $createEventService; + private EventRepositoryInterface $eventRepository; + private EventSettingsRepositoryInterface $eventSettingsRepository; + private OrganizerRepositoryInterface $organizerRepository; + private DatabaseManager $databaseManager; + private EventStatisticRepositoryInterface $eventStatisticsRepository; + private HtmlPurifierService $purifier; + private ImageRepositoryInterface $imageRepository; + private Repository $config; + private FilesystemManager $filesystemManager; + private EventOccurrenceRepositoryInterface $occurrenceRepository; + + private \HiEvents\Repository\Interfaces\CheckInListRepositoryInterface $checkInListRepository; + protected function setUp(): void { parent::setUp(); @@ -48,6 +63,8 @@ protected function setUp(): void $this->imageRepository = Mockery::mock(ImageRepositoryInterface::class); $this->config = Mockery::mock(Repository::class); $this->filesystemManager = Mockery::mock(FilesystemManager::class); + $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->checkInListRepository = Mockery::mock(\HiEvents\Repository\Interfaces\CheckInListRepositoryInterface::class); $this->createEventService = new CreateEventService( $this->eventRepository, @@ -59,6 +76,8 @@ protected function setUp(): void $this->imageRepository, $this->config, $this->filesystemManager, + $this->occurrenceRepository, + $this->checkInListRepository, ); } @@ -68,7 +87,7 @@ protected function tearDown(): void parent::tearDown(); } - public function testCreateEventSuccess(): void + public function test_create_event_success(): void { $eventData = $this->createMockEventDomainObject(); $eventSettings = $this->createMockEventSettingDomainObject(); @@ -112,6 +131,11 @@ public function testCreateEventSuccess(): void $arg['sales_total_gross'] === 0; })); + // Every event now gets a system-default check-in list on creation. + $this->checkInListRepository->shouldReceive('create')->once() + ->with(Mockery::on(fn ($arg) => ($arg['is_system_default'] ?? false) === true + && ($arg['event_id'] ?? null) === $eventData->getId())); + // Mock event cover creation $this->config->shouldReceive('get') ->with('filesystems.public') @@ -122,32 +146,34 @@ public function testCreateEventSuccess(): void $this->config->shouldReceive('get') ->with('filesystems.default') ->andReturn('local'); - + $mockDisk = Mockery::mock(); $mockDisk->shouldReceive('exists') ->with('event-covers/CONFERENCE.jpg') ->andReturn(true); - + $this->filesystemManager->shouldReceive('disk') ->with('public') ->andReturn($mockDisk); - + $this->imageRepository->shouldReceive('create') ->once(); $this->purifier->shouldReceive('purify')->andReturn('Test Description'); - $result = $this->createEventService->createEvent($eventData, $eventSettings); + $this->occurrenceRepository->shouldReceive('create')->once(); + + $result = $this->createEventService->createEvent($eventData, '2023-01-01 00:00:00', '2023-01-02 00:00:00', $eventSettings); $this->assertEquals($eventData->getId(), $result->getId()); } - public function testCreateEventWithoutEventSettings(): void + public function test_create_event_without_event_settings(): void { $eventData = $this->createMockEventDomainObject(); $organizer = $this->createMockOrganizerDomainObject() ->shouldReceive('getOrganizerSettings') - ->andReturn(new OrganizerSettingDomainObject()) + ->andReturn(new OrganizerSettingDomainObject) ->getMock(); $this->databaseManager->shouldReceive('transaction')->once()->andReturnUsing(function ($callback) { @@ -175,6 +201,7 @@ public function testCreateEventWithoutEventSettings(): void })); $this->eventStatisticsRepository->shouldReceive('create'); + $this->checkInListRepository->shouldReceive('create'); // Mock event cover creation $this->config->shouldReceive('get') @@ -186,12 +213,12 @@ public function testCreateEventWithoutEventSettings(): void $this->config->shouldReceive('get') ->with('filesystems.default') ->andReturn('local'); - + $mockDisk = Mockery::mock(); $mockDisk->shouldReceive('exists') ->with('event-covers/CONFERENCE.jpg') ->andReturn(false); // No cover exists for this test - + $this->filesystemManager->shouldReceive('disk') ->with('public') ->andReturn($mockDisk); @@ -201,7 +228,58 @@ public function testCreateEventWithoutEventSettings(): void $this->assertTrue(true); } - public function testCreateEventThrowsOrganizerNotFoundException(): void + public function test_create_recurring_event_defaults_collection_method_to_per_order(): void + { + $eventData = Mockery::mock(EventDomainObject::class, static function ($mock) { + $mock->shouldReceive('getId')->andReturn(1); + $mock->shouldReceive('getTitle')->andReturn('Weekly Yoga'); + $mock->shouldReceive('getOrganizerId')->andReturn(1); + $mock->shouldReceive('getAccountId')->andReturn(1); + $mock->shouldReceive('getTimezone')->andReturn('UTC'); + $mock->shouldReceive('getCurrency')->andReturn('USD'); + $mock->shouldReceive('getDescription')->andReturn('Desc'); + $mock->shouldReceive('getLocationDetails')->andReturn(null); + $mock->shouldReceive('getUserId')->andReturn(1); + $mock->shouldReceive('getStatus')->andReturn('DRAFT'); + $mock->shouldReceive('getCategory')->andReturn('WELLNESS'); + $mock->shouldReceive('getAttributes')->andReturn([]); + $mock->shouldReceive('getType')->andReturn('RECURRING'); + $mock->shouldReceive('getRecurrenceRule')->andReturn(null); + }); + + $organizer = $this->createMockOrganizerDomainObject() + ->shouldReceive('getOrganizerSettings') + ->andReturn(new OrganizerSettingDomainObject) + ->getMock(); + + $this->databaseManager->shouldReceive('transaction')->once()->andReturnUsing(fn ($cb) => $cb()); + + $this->organizerRepository + ->shouldReceive('loadRelation')->with(OrganizerSettingDomainObject::class)->once()->andReturnSelf() + ->getMock() + ->shouldReceive('findFirstWhere')->andReturn($organizer); + + $this->eventRepository->shouldReceive('create')->andReturn($eventData); + $this->purifier->shouldReceive('purify')->andReturn('Desc'); + + $this->config->shouldReceive('get')->with('filesystems.public')->andReturn('public'); + $this->config->shouldReceive('get')->with('app.event_categories_cover_images_path')->andReturn('event-covers'); + $mockDisk = Mockery::mock(); + $mockDisk->shouldReceive('exists')->andReturn(false); + $this->filesystemManager->shouldReceive('disk')->with('public')->andReturn($mockDisk); + + $this->eventSettingsRepository->shouldReceive('create') + ->once() + ->with(Mockery::on(fn ($arg) => $arg['attendee_details_collection_method'] === AttendeeDetailsCollectionMethod::PER_ORDER->value)); + + $this->eventStatisticsRepository->shouldReceive('create'); + $this->checkInListRepository->shouldReceive('create'); + + $this->createEventService->createEvent($eventData); + $this->assertTrue(true); + } + + public function test_create_event_throws_organizer_not_found_exception(): void { $eventData = $this->createMockEventDomainObject(); @@ -223,12 +301,12 @@ public function testCreateEventThrowsOrganizerNotFoundException(): void $this->createEventService->createEvent($eventData); } - public function testCreateEventWithEventCoverCreatesImageRecord(): void + public function test_create_event_with_event_cover_creates_image_record(): void { $eventData = $this->createMockEventDomainObject(); $organizer = $this->createMockOrganizerDomainObject() ->shouldReceive('getOrganizerSettings') - ->andReturn(new OrganizerSettingDomainObject()) + ->andReturn(new OrganizerSettingDomainObject) ->getMock(); $this->databaseManager->shouldReceive('transaction')->once()->andReturnUsing(function ($callback) { @@ -256,16 +334,16 @@ public function testCreateEventWithEventCoverCreatesImageRecord(): void $this->config->shouldReceive('get') ->with('filesystems.default') ->andReturn('local'); - + $mockDisk = Mockery::mock(); $mockDisk->shouldReceive('exists') ->with('event-covers/CONFERENCE.jpg') ->andReturn(true); - + $this->filesystemManager->shouldReceive('disk') ->with('public') ->andReturn($mockDisk); - + // Verify image record is created with correct data $this->imageRepository->shouldReceive('create') ->once() @@ -285,6 +363,7 @@ public function testCreateEventWithEventCoverCreatesImageRecord(): void })); $this->eventStatisticsRepository->shouldReceive('create'); + $this->checkInListRepository->shouldReceive('create'); $this->purifier->shouldReceive('purify')->andReturn('Test Description'); @@ -293,12 +372,12 @@ public function testCreateEventWithEventCoverCreatesImageRecord(): void $this->assertTrue(true); } - public function testCreateEventWithoutEventCoverDoesNotCreateImageRecord(): void + public function test_create_event_without_event_cover_does_not_create_image_record(): void { $eventData = $this->createMockEventDomainObjectWithCategory('MUSIC'); $organizer = $this->createMockOrganizerDomainObject() ->shouldReceive('getOrganizerSettings') - ->andReturn(new OrganizerSettingDomainObject()) + ->andReturn(new OrganizerSettingDomainObject) ->getMock(); $this->databaseManager->shouldReceive('transaction')->once()->andReturnUsing(function ($callback) { @@ -323,16 +402,16 @@ public function testCreateEventWithoutEventCoverDoesNotCreateImageRecord(): void $this->config->shouldReceive('get') ->with('app.event_categories_cover_images_path') ->andReturn('event-covers'); - + $mockDisk = Mockery::mock(); $mockDisk->shouldReceive('exists') ->with('event-covers/MUSIC.jpg') ->andReturn(false); - + $this->filesystemManager->shouldReceive('disk') ->with('public') ->andReturn($mockDisk); - + // Image repository should NOT be called $this->imageRepository->shouldNotReceive('create'); @@ -343,6 +422,7 @@ public function testCreateEventWithoutEventCoverDoesNotCreateImageRecord(): void })); $this->eventStatisticsRepository->shouldReceive('create'); + $this->checkInListRepository->shouldReceive('create'); $this->purifier->shouldReceive('purify')->andReturn('Test Description'); @@ -368,6 +448,8 @@ private function createMockEventDomainObject(): EventDomainObject $mock->shouldReceive('getStatus')->andReturn('active'); $mock->shouldReceive('getCategory')->andReturn('CONFERENCE'); $mock->shouldReceive('getAttributes')->andReturn([]); + $mock->shouldReceive('getType')->andReturn('SINGLE'); + $mock->shouldReceive('getRecurrenceRule')->andReturn(null); }); } @@ -407,6 +489,8 @@ private function createMockEventDomainObjectWithCategory(string $category): Even $mock->shouldReceive('getStatus')->andReturn('active'); $mock->shouldReceive('getCategory')->andReturn($category); $mock->shouldReceive('getAttributes')->andReturn([]); + $mock->shouldReceive('getType')->andReturn('SINGLE'); + $mock->shouldReceive('getRecurrenceRule')->andReturn(null); }); } } diff --git a/backend/tests/Unit/Services/Domain/Event/EventOccurrenceGeneratorServiceTest.php b/backend/tests/Unit/Services/Domain/Event/EventOccurrenceGeneratorServiceTest.php new file mode 100644 index 0000000000..e151ab91d8 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Event/EventOccurrenceGeneratorServiceTest.php @@ -0,0 +1,936 @@ +ruleParser = Mockery::mock(RecurrenceRuleParserService::class); + $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->waitlistEntryRepository = Mockery::mock(WaitlistEntryRepositoryInterface::class); + // Most tests don't exercise stale-deletion; default to a no-op so the + // few that do can override with explicit expectations. + $this->waitlistEntryRepository->shouldReceive('updateWhere')->byDefault(); + + $this->service = new EventOccurrenceGeneratorService( + $this->ruleParser, + $this->occurrenceRepository, + $this->waitlistEntryRepository, + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + /** + * Mocks the two batch lookups the generator runs to decide which existing + * occurrences are "in use" and therefore protected from soft-deletion: + * occurrences pointed at by an active order_item OR an active attendee. + */ + private function mockDbBatchQuery( + array $occurrenceIdsWithOrders = [], + array $occurrenceIdsWithAttendees = [], + ): void { + $orderItemsBuilder = Mockery::mock(\Illuminate\Database\Query\Builder::class); + $orderItemsBuilder->shouldReceive('whereIn')->andReturnSelf(); + $orderItemsBuilder->shouldReceive('whereNull')->andReturnSelf(); + $orderItemsBuilder->shouldReceive('distinct')->andReturnSelf(); + $orderItemsBuilder->shouldReceive('pluck')->andReturn(collect($occurrenceIdsWithOrders)); + + $attendeesBuilder = Mockery::mock(\Illuminate\Database\Query\Builder::class); + $attendeesBuilder->shouldReceive('whereIn')->andReturnSelf(); + $attendeesBuilder->shouldReceive('whereNull')->andReturnSelf(); + $attendeesBuilder->shouldReceive('distinct')->andReturnSelf(); + $attendeesBuilder->shouldReceive('pluck')->andReturn(collect($occurrenceIdsWithAttendees)); + + DB::shouldReceive('table') + ->with('order_items') + ->andReturn($orderItemsBuilder); + DB::shouldReceive('table') + ->with('attendees') + ->andReturn($attendeesBuilder); + } + + public function testNewOccurrencesAreCreatedWhenNoneExist(): void + { + $event = $this->createMockEvent(); + $recurrenceRule = ['frequency' => 'daily']; + + $candidateStart = CarbonImmutable::parse('2025-03-01 10:00:00'); + $candidateEnd = CarbonImmutable::parse('2025-03-01 11:00:00'); + + $this->ruleParser + ->shouldReceive('parse') + ->with($recurrenceRule, 'UTC') + ->once() + ->andReturn(collect([ + ['start' => $candidateStart, 'end' => $candidateEnd, 'capacity' => 100], + ])); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 1]) + ->once() + ->andReturn(collect()); + + $createdOccurrence = $this->createOccurrenceDomainObject( + id: 10, + startDate: '2025-03-01 10:00:00', + endDate: '2025-03-01 11:00:00', + ); + + $this->occurrenceRepository + ->shouldReceive('create') + ->with(Mockery::on(function ($arg) { + return $arg[EventOccurrenceDomainObjectAbstract::EVENT_ID] === 1 + && $arg[EventOccurrenceDomainObjectAbstract::START_DATE] === '2025-03-01 10:00:00' + && $arg[EventOccurrenceDomainObjectAbstract::END_DATE] === '2025-03-01 11:00:00' + && $arg[EventOccurrenceDomainObjectAbstract::STATUS] === EventOccurrenceStatus::ACTIVE->name + && $arg[EventOccurrenceDomainObjectAbstract::CAPACITY] === 100 + && $arg[EventOccurrenceDomainObjectAbstract::USED_CAPACITY] === 0 + && $arg[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === false; + })) + ->once() + ->andReturn($createdOccurrence); + + $this->occurrenceRepository->shouldNotReceive('deleteWhere'); + + $result = $this->service->generate($event, $recurrenceRule); + + $this->assertCount(1, $result); + $this->assertEquals(10, $result->first()->getId()); + } + + public function testMultipleNewOccurrencesCreated(): void + { + $event = $this->createMockEvent(); + $recurrenceRule = ['frequency' => 'daily']; + + $candidates = collect([ + [ + 'start' => CarbonImmutable::parse('2025-03-01 10:00:00'), + 'end' => CarbonImmutable::parse('2025-03-01 11:00:00'), + 'capacity' => 50, + ], + [ + 'start' => CarbonImmutable::parse('2025-03-02 10:00:00'), + 'end' => CarbonImmutable::parse('2025-03-02 11:00:00'), + 'capacity' => 50, + ], + ]); + + $this->ruleParser + ->shouldReceive('parse') + ->with($recurrenceRule, 'UTC') + ->once() + ->andReturn($candidates); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 1]) + ->once() + ->andReturn(collect()); + + $occ1 = $this->createOccurrenceDomainObject(id: 10, startDate: '2025-03-01 10:00:00'); + $occ2 = $this->createOccurrenceDomainObject(id: 11, startDate: '2025-03-02 10:00:00'); + + $this->occurrenceRepository + ->shouldReceive('create') + ->twice() + ->andReturn($occ1, $occ2); + + $result = $this->service->generate($event, $recurrenceRule); + + $this->assertCount(2, $result); + } + + public function testExistingOccurrenceWithoutOrdersAndNotOverriddenIsUpdatedInPlace(): void + { + $event = $this->createMockEvent(); + $recurrenceRule = ['frequency' => 'daily']; + + $candidateStart = CarbonImmutable::parse('2025-03-01 10:00:00'); + $candidateEnd = CarbonImmutable::parse('2025-03-01 12:00:00'); + + $this->ruleParser + ->shouldReceive('parse') + ->with($recurrenceRule, 'UTC') + ->once() + ->andReturn(collect([ + ['start' => $candidateStart, 'end' => $candidateEnd, 'capacity' => 200], + ])); + + $existingOccurrence = $this->createOccurrenceDomainObject( + id: 5, + startDate: '2025-03-01 10:00:00', + endDate: '2025-03-01 11:00:00', + isOverridden: false, + ); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 1]) + ->once() + ->andReturn(collect([$existingOccurrence])); + + $this->mockDbBatchQuery([]); + + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->with( + Mockery::on(function ($attributes) { + return $attributes[EventOccurrenceDomainObjectAbstract::START_DATE] === '2025-03-01 10:00:00' + && $attributes[EventOccurrenceDomainObjectAbstract::END_DATE] === '2025-03-01 12:00:00' + && $attributes[EventOccurrenceDomainObjectAbstract::CAPACITY] === 200; + }), + [EventOccurrenceDomainObjectAbstract::ID => 5] + ) + ->once(); + + $updatedOccurrence = $this->createOccurrenceDomainObject( + id: 5, + startDate: '2025-03-01 10:00:00', + endDate: '2025-03-01 12:00:00', + ); + + $this->occurrenceRepository + ->shouldReceive('findById') + ->with(5) + ->once() + ->andReturn($updatedOccurrence); + + $this->occurrenceRepository->shouldNotReceive('create'); + $this->occurrenceRepository->shouldNotReceive('deleteWhere'); + + $result = $this->service->generate($event, $recurrenceRule); + + $this->assertCount(1, $result); + $this->assertEquals(5, $result->first()->getId()); + $this->assertEquals('2025-03-01 12:00:00', $result->first()->getEndDate()); + } + + public function testExistingOccurrenceWithOrdersIsNotModified(): void + { + $event = $this->createMockEvent(); + $recurrenceRule = ['frequency' => 'daily']; + + $candidateStart = CarbonImmutable::parse('2025-03-01 10:00:00'); + $candidateEnd = CarbonImmutable::parse('2025-03-01 12:00:00'); + + $this->ruleParser + ->shouldReceive('parse') + ->with($recurrenceRule, 'UTC') + ->once() + ->andReturn(collect([ + ['start' => $candidateStart, 'end' => $candidateEnd, 'capacity' => 200], + ])); + + $existingOccurrence = $this->createOccurrenceDomainObject( + id: 5, + startDate: '2025-03-01 10:00:00', + endDate: '2025-03-01 11:00:00', + isOverridden: false, + ); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 1]) + ->once() + ->andReturn(collect([$existingOccurrence])); + + $this->mockDbBatchQuery([5]); + + $this->occurrenceRepository->shouldNotReceive('updateWhere'); + $this->occurrenceRepository->shouldNotReceive('findById'); + $this->occurrenceRepository->shouldNotReceive('create'); + $this->occurrenceRepository->shouldNotReceive('deleteWhere'); + + $result = $this->service->generate($event, $recurrenceRule); + + $this->assertCount(1, $result); + $this->assertEquals(5, $result->first()->getId()); + $this->assertEquals('2025-03-01 11:00:00', $result->first()->getEndDate()); + } + + public function testExistingOverriddenOccurrenceIsNotModified(): void + { + $event = $this->createMockEvent(); + $recurrenceRule = ['frequency' => 'daily']; + + $candidateStart = CarbonImmutable::parse('2025-03-01 10:00:00'); + $candidateEnd = CarbonImmutable::parse('2025-03-01 12:00:00'); + + $this->ruleParser + ->shouldReceive('parse') + ->with($recurrenceRule, 'UTC') + ->once() + ->andReturn(collect([ + ['start' => $candidateStart, 'end' => $candidateEnd, 'capacity' => 200], + ])); + + $existingOccurrence = $this->createOccurrenceDomainObject( + id: 5, + startDate: '2025-03-01 10:00:00', + endDate: '2025-03-01 11:00:00', + isOverridden: true, + ); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 1]) + ->once() + ->andReturn(collect([$existingOccurrence])); + + $this->mockDbBatchQuery([]); + + $this->occurrenceRepository->shouldNotReceive('updateWhere'); + $this->occurrenceRepository->shouldNotReceive('findById'); + $this->occurrenceRepository->shouldNotReceive('create'); + $this->occurrenceRepository->shouldNotReceive('deleteWhere'); + + $result = $this->service->generate($event, $recurrenceRule); + + $this->assertCount(1, $result); + $this->assertEquals(5, $result->first()->getId()); + $this->assertEquals('2025-03-01 11:00:00', $result->first()->getEndDate()); + } + + public function testStaleOccurrenceWithNoOrdersAndNotOverriddenIsSoftDeleted(): void + { + $event = $this->createMockEvent(); + $recurrenceRule = ['frequency' => 'daily']; + + $this->ruleParser + ->shouldReceive('parse') + ->with($recurrenceRule, 'UTC') + ->once() + ->andReturn(collect([ + [ + 'start' => CarbonImmutable::parse('2025-03-02 10:00:00'), + 'end' => CarbonImmutable::parse('2025-03-02 11:00:00'), + 'capacity' => 100, + ], + ])); + + $staleOccurrence = $this->createOccurrenceDomainObject( + id: 5, + startDate: '2025-03-01 10:00:00', + endDate: '2025-03-01 11:00:00', + isOverridden: false, + ); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 1]) + ->once() + ->andReturn(collect([$staleOccurrence])); + + $this->mockDbBatchQuery([]); + + $newOccurrence = $this->createOccurrenceDomainObject( + id: 10, + startDate: '2025-03-02 10:00:00', + endDate: '2025-03-02 11:00:00', + ); + + $this->occurrenceRepository + ->shouldReceive('create') + ->once() + ->andReturn($newOccurrence); + + $this->occurrenceRepository + ->shouldReceive('deleteWhere') + ->with([[EventOccurrenceDomainObjectAbstract::ID, 'in', [5]]]) + ->once(); + + $result = $this->service->generate($event, $recurrenceRule); + + $this->assertCount(1, $result); + $this->assertEquals(10, $result->first()->getId()); + } + + public function testStaleOccurrenceWithOrdersIsMarkedOverriddenAndNotDeleted(): void + { + $event = $this->createMockEvent(); + $recurrenceRule = ['frequency' => 'daily']; + + $this->ruleParser + ->shouldReceive('parse') + ->with($recurrenceRule, 'UTC') + ->once() + ->andReturn(collect([ + [ + 'start' => CarbonImmutable::parse('2025-03-02 10:00:00'), + 'end' => CarbonImmutable::parse('2025-03-02 11:00:00'), + 'capacity' => 100, + ], + ])); + + $staleWithOrders = $this->createOccurrenceDomainObject( + id: 5, + startDate: '2025-03-01 10:00:00', + endDate: '2025-03-01 11:00:00', + isOverridden: false, + ); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 1]) + ->once() + ->andReturn(collect([$staleWithOrders])); + + $this->mockDbBatchQuery([5]); + + $newOccurrence = $this->createOccurrenceDomainObject( + id: 10, + startDate: '2025-03-02 10:00:00', + endDate: '2025-03-02 11:00:00', + ); + + $this->occurrenceRepository + ->shouldReceive('create') + ->once() + ->andReturn($newOccurrence); + + $this->occurrenceRepository->shouldNotReceive('deleteWhere'); + + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + [EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => true], + [EventOccurrenceDomainObjectAbstract::ID => 5], + ); + + $result = $this->service->generate($event, $recurrenceRule); + + $this->assertCount(1, $result); + $this->assertEquals(10, $result->first()->getId()); + } + + public function testStaleOccurrenceWithAttendeesButNoOrderItemsIsMarkedOverriddenAndNotDeleted(): void + { + // Regression: matches the single/bulk delete handlers, which both + // refuse to delete an occurrence that has any attendees pointing at + // it — even if no order_item row does. Regeneration was previously + // only checking order_items, so changing the recurrence rule could + // soft-delete an attendee-bearing occurrence in import / partial- + // restore scenarios where the two tables disagree. + $event = $this->createMockEvent(); + $recurrenceRule = ['frequency' => 'daily']; + + $this->ruleParser + ->shouldReceive('parse') + ->with($recurrenceRule, 'UTC') + ->once() + ->andReturn(collect([ + [ + 'start' => CarbonImmutable::parse('2025-03-02 10:00:00'), + 'end' => CarbonImmutable::parse('2025-03-02 11:00:00'), + 'capacity' => 100, + ], + ])); + + $staleWithAttendees = $this->createOccurrenceDomainObject( + id: 5, + startDate: '2025-03-01 10:00:00', + endDate: '2025-03-01 11:00:00', + isOverridden: false, + ); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 1]) + ->once() + ->andReturn(collect([$staleWithAttendees])); + + // No order_item rows pointing at occ 5, but the attendees query does + // return it — must still be protected. + $this->mockDbBatchQuery(occurrenceIdsWithOrders: [], occurrenceIdsWithAttendees: [5]); + + $newOccurrence = $this->createOccurrenceDomainObject( + id: 10, + startDate: '2025-03-02 10:00:00', + endDate: '2025-03-02 11:00:00', + ); + + $this->occurrenceRepository + ->shouldReceive('create') + ->once() + ->andReturn($newOccurrence); + + $this->occurrenceRepository->shouldNotReceive('deleteWhere'); + + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + [EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => true], + [EventOccurrenceDomainObjectAbstract::ID => 5], + ); + + $result = $this->service->generate($event, $recurrenceRule); + + $this->assertCount(1, $result); + $this->assertEquals(10, $result->first()->getId()); + } + + public function testStaleOverriddenOccurrenceIsNotDeleted(): void + { + $event = $this->createMockEvent(); + $recurrenceRule = ['frequency' => 'daily']; + + $this->ruleParser + ->shouldReceive('parse') + ->with($recurrenceRule, 'UTC') + ->once() + ->andReturn(collect([ + [ + 'start' => CarbonImmutable::parse('2025-03-02 10:00:00'), + 'end' => CarbonImmutable::parse('2025-03-02 11:00:00'), + 'capacity' => 100, + ], + ])); + + $staleOverridden = $this->createOccurrenceDomainObject( + id: 5, + startDate: '2025-03-01 10:00:00', + endDate: '2025-03-01 11:00:00', + isOverridden: true, + ); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 1]) + ->once() + ->andReturn(collect([$staleOverridden])); + + $this->mockDbBatchQuery([]); + + $newOccurrence = $this->createOccurrenceDomainObject( + id: 10, + startDate: '2025-03-02 10:00:00', + endDate: '2025-03-02 11:00:00', + ); + + $this->occurrenceRepository + ->shouldReceive('create') + ->once() + ->andReturn($newOccurrence); + + $this->occurrenceRepository->shouldNotReceive('deleteWhere'); + + $result = $this->service->generate($event, $recurrenceRule); + + $this->assertCount(1, $result); + } + + public function testMixedScenarioWithNewUpdatedSkippedAndStaleOccurrences(): void + { + $event = $this->createMockEvent(); + $recurrenceRule = ['frequency' => 'daily']; + + $candidates = collect([ + [ + 'start' => CarbonImmutable::parse('2025-03-01 10:00:00'), + 'end' => CarbonImmutable::parse('2025-03-01 11:00:00'), + 'capacity' => 100, + ], + [ + 'start' => CarbonImmutable::parse('2025-03-02 10:00:00'), + 'end' => CarbonImmutable::parse('2025-03-02 11:00:00'), + 'capacity' => 100, + ], + [ + 'start' => CarbonImmutable::parse('2025-03-03 10:00:00'), + 'end' => CarbonImmutable::parse('2025-03-03 11:00:00'), + 'capacity' => 100, + ], + [ + 'start' => CarbonImmutable::parse('2025-03-05 10:00:00'), + 'end' => CarbonImmutable::parse('2025-03-05 11:00:00'), + 'capacity' => 100, + ], + ]); + + $this->ruleParser + ->shouldReceive('parse') + ->with($recurrenceRule, 'UTC') + ->once() + ->andReturn($candidates); + + $existingUpdatable = $this->createOccurrenceDomainObject( + id: 1, startDate: '2025-03-01 10:00:00', endDate: '2025-03-01 10:30:00', isOverridden: false, + ); + $existingWithOrders = $this->createOccurrenceDomainObject( + id: 2, startDate: '2025-03-02 10:00:00', endDate: '2025-03-02 10:30:00', isOverridden: false, + ); + $existingOverridden = $this->createOccurrenceDomainObject( + id: 3, startDate: '2025-03-03 10:00:00', endDate: '2025-03-03 10:30:00', isOverridden: true, + ); + $existingStale = $this->createOccurrenceDomainObject( + id: 4, startDate: '2025-03-04 10:00:00', endDate: '2025-03-04 10:30:00', isOverridden: false, + ); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 1]) + ->once() + ->andReturn(collect([$existingUpdatable, $existingWithOrders, $existingOverridden, $existingStale])); + + $this->mockDbBatchQuery([2]); + + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->with( + Mockery::on(function ($attributes) { + return $attributes[EventOccurrenceDomainObjectAbstract::START_DATE] === '2025-03-01 10:00:00' + && $attributes[EventOccurrenceDomainObjectAbstract::END_DATE] === '2025-03-01 11:00:00' + && $attributes[EventOccurrenceDomainObjectAbstract::CAPACITY] === 100; + }), + [EventOccurrenceDomainObjectAbstract::ID => 1] + ) + ->once(); + + $updatedOcc1 = $this->createOccurrenceDomainObject( + id: 1, startDate: '2025-03-01 10:00:00', endDate: '2025-03-01 11:00:00', + ); + + $this->occurrenceRepository + ->shouldReceive('findById') + ->with(1) + ->once() + ->andReturn($updatedOcc1); + + $newOcc = $this->createOccurrenceDomainObject( + id: 20, startDate: '2025-03-05 10:00:00', endDate: '2025-03-05 11:00:00', + ); + + $this->occurrenceRepository + ->shouldReceive('create') + ->once() + ->andReturn($newOcc); + + $this->occurrenceRepository + ->shouldReceive('deleteWhere') + ->with([[EventOccurrenceDomainObjectAbstract::ID, 'in', [4]]]) + ->once(); + + $result = $this->service->generate($event, $recurrenceRule); + + $this->assertCount(4, $result); + + $ids = $result->map(fn ($occ) => $occ->getId())->toArray(); + $this->assertContains(1, $ids); + $this->assertContains(2, $ids); + $this->assertContains(3, $ids); + $this->assertContains(20, $ids); + $this->assertNotContains(4, $ids); + } + + public function testEventTimezoneIsPassedToParser(): void + { + $event = $this->createMockEvent(timezone: 'America/New_York'); + $recurrenceRule = ['frequency' => 'daily']; + + $this->ruleParser + ->shouldReceive('parse') + ->with($recurrenceRule, 'America/New_York') + ->once() + ->andReturn(collect()); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(collect()); + + $result = $this->service->generate($event, $recurrenceRule); + + $this->assertCount(0, $result); + } + + public function testNullTimezoneDefaultsToUtc(): void + { + $event = $this->createMockEvent(timezone: null); + $recurrenceRule = ['frequency' => 'daily']; + + $this->ruleParser + ->shouldReceive('parse') + ->with($recurrenceRule, 'UTC') + ->once() + ->andReturn(collect()); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(collect()); + + $result = $this->service->generate($event, $recurrenceRule); + + $this->assertCount(0, $result); + } + + public function testNewOccurrenceWithNullEndDate(): void + { + $event = $this->createMockEvent(); + $recurrenceRule = ['frequency' => 'daily']; + + $candidateStart = CarbonImmutable::parse('2025-03-01 10:00:00'); + + $this->ruleParser + ->shouldReceive('parse') + ->with($recurrenceRule, 'UTC') + ->once() + ->andReturn(collect([ + ['start' => $candidateStart, 'end' => null, 'capacity' => null], + ])); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(collect()); + + $createdOccurrence = $this->createOccurrenceDomainObject( + id: 10, + startDate: '2025-03-01 10:00:00', + endDate: null, + ); + + $this->occurrenceRepository + ->shouldReceive('create') + ->with(Mockery::on(function ($arg) { + return $arg[EventOccurrenceDomainObjectAbstract::END_DATE] === null + && $arg[EventOccurrenceDomainObjectAbstract::CAPACITY] === null; + })) + ->once() + ->andReturn($createdOccurrence); + + $result = $this->service->generate($event, $recurrenceRule); + + $this->assertCount(1, $result); + $this->assertNull($result->first()->getEndDate()); + } + + public function testEmptyCandidatesWithExistingOccurrencesDeletesStale(): void + { + $event = $this->createMockEvent(); + $recurrenceRule = ['frequency' => 'daily']; + + $this->ruleParser + ->shouldReceive('parse') + ->with($recurrenceRule, 'UTC') + ->once() + ->andReturn(collect()); + + $staleOccurrence = $this->createOccurrenceDomainObject( + id: 5, + startDate: '2025-03-01 10:00:00', + isOverridden: false, + ); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(collect([$staleOccurrence])); + + $this->mockDbBatchQuery([]); + + $this->occurrenceRepository + ->shouldReceive('deleteWhere') + ->with([[EventOccurrenceDomainObjectAbstract::ID, 'in', [5]]]) + ->once(); + + $result = $this->service->generate($event, $recurrenceRule); + + $this->assertCount(0, $result); + } + + public function testStaleOccurrenceWaitlistEntriesAreCancelledBeforeDeletion(): void + { + // Regression for the regenerate-strands-waitlist bug: removeStaleOccurrences + // soft-deletes orphaned occurrences. The FK is nullOnDelete which only + // fires on hard deletes, so without explicit waitlist cleanup, WAITING/ + // OFFERED entries are left pointing at soft-deleted rows and crash + // ProcessWaitlistService on the next CapacityChangedEvent. + $event = $this->createMockEvent(); + $recurrenceRule = ['frequency' => 'daily']; + + $this->ruleParser + ->shouldReceive('parse') + ->with($recurrenceRule, 'UTC') + ->once() + ->andReturn(collect()); + + $stale = $this->createOccurrenceDomainObject( + id: 5, + startDate: '2025-03-01 10:00:00', + isOverridden: false, + ); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(collect([$stale])); + + $this->mockDbBatchQuery([]); + + // Override the default no-op mock with a strict expectation: the + // waitlist cancel must run with the right scope before the delete. + $this->waitlistEntryRepository = Mockery::mock(WaitlistEntryRepositoryInterface::class); + $this->waitlistEntryRepository + ->shouldReceive('updateWhere') + ->with( + ['status' => WaitlistEntryStatus::CANCELLED->name], + [ + ['event_id', 'in', [1]], + ['event_occurrence_id', 'in', [5]], + ['status', 'in', [ + WaitlistEntryStatus::WAITING->name, + WaitlistEntryStatus::OFFERED->name, + ]], + ], + ) + ->once(); + + $this->service = new EventOccurrenceGeneratorService( + $this->ruleParser, + $this->occurrenceRepository, + $this->waitlistEntryRepository, + ); + + $this->occurrenceRepository + ->shouldReceive('deleteWhere') + ->with([[EventOccurrenceDomainObjectAbstract::ID, 'in', [5]]]) + ->once(); + + $result = $this->service->generate($event, $recurrenceRule); + + $this->assertCount(0, $result); + } + + public function testEmptyCandidatesWithOverriddenExistingOccurrenceKeepsIt(): void + { + $event = $this->createMockEvent(); + $recurrenceRule = ['frequency' => 'daily']; + + $this->ruleParser + ->shouldReceive('parse') + ->with($recurrenceRule, 'UTC') + ->once() + ->andReturn(collect()); + + $overriddenOccurrence = $this->createOccurrenceDomainObject( + id: 5, + startDate: '2025-03-01 10:00:00', + isOverridden: true, + ); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(collect([$overriddenOccurrence])); + + $this->mockDbBatchQuery([]); + + $this->occurrenceRepository->shouldNotReceive('deleteWhere'); + + $result = $this->service->generate($event, $recurrenceRule); + + $this->assertCount(0, $result); + } + + public function testExistingOccurrenceWithOrdersAndOverriddenIsSkipped(): void + { + $event = $this->createMockEvent(); + $recurrenceRule = ['frequency' => 'daily']; + + $candidateStart = CarbonImmutable::parse('2025-03-01 10:00:00'); + $candidateEnd = CarbonImmutable::parse('2025-03-01 12:00:00'); + + $this->ruleParser + ->shouldReceive('parse') + ->with($recurrenceRule, 'UTC') + ->once() + ->andReturn(collect([ + ['start' => $candidateStart, 'end' => $candidateEnd, 'capacity' => 200], + ])); + + $existingOccurrence = $this->createOccurrenceDomainObject( + id: 5, + startDate: '2025-03-01 10:00:00', + endDate: '2025-03-01 11:00:00', + isOverridden: true, + ); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(collect([$existingOccurrence])); + + $this->mockDbBatchQuery([5]); + + $this->occurrenceRepository->shouldNotReceive('updateWhere'); + $this->occurrenceRepository->shouldNotReceive('findById'); + + $result = $this->service->generate($event, $recurrenceRule); + + $this->assertCount(1, $result); + $this->assertSame($existingOccurrence, $result->first()); + } + + private function createMockEvent(int $id = 1, ?string $timezone = 'UTC'): EventDomainObject + { + $mock = Mockery::mock(EventDomainObject::class); + $mock->shouldReceive('getId')->andReturn($id); + $mock->shouldReceive('getTimezone')->andReturn($timezone); + + return $mock; + } + + private function createOccurrenceDomainObject( + int $id, + string $startDate, + ?string $endDate = null, + bool $isOverridden = false, + ?int $capacity = null, + int $eventId = 1, + ): EventOccurrenceDomainObject { + $occ = new EventOccurrenceDomainObject(); + $occ->setId($id); + $occ->setEventId($eventId); + $occ->setShortId('oc_test' . $id); + $occ->setStartDate($startDate); + $occ->setEndDate($endDate); + $occ->setIsOverridden($isOverridden); + $occ->setCapacity($capacity); + + return $occ; + } +} diff --git a/backend/tests/Unit/Services/Domain/Event/EventStatsFetchServiceTest.php b/backend/tests/Unit/Services/Domain/Event/EventStatsFetchServiceTest.php new file mode 100644 index 0000000000..bc86f48db6 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Event/EventStatsFetchServiceTest.php @@ -0,0 +1,84 @@ +db = Mockery::mock(DatabaseManager::class); + $this->eventRepository = Mockery::mock(EventRepositoryInterface::class); + $this->service = new EventStatsFetchService($this->db, $this->eventRepository); + } + + public function test_occurrence_scoped_stats_bind_event_id_with_occurrence_id(): void + { + $this->db + ->shouldReceive('selectOne') + ->once() + ->with( + Mockery::on(static fn (string $sql): bool => str_contains($sql, 'eos.event_occurrence_id = :occurrenceId') + && str_contains($sql, 'eos.event_id = :eventId')), + [ + 'occurrenceId' => 200, + 'eventId' => 10, + ], + ) + ->andReturn((object) [ + 'total_products_sold' => 0, + 'total_orders' => 0, + 'total_gross_sales' => 0, + 'total_tax' => 0, + 'total_fees' => 0, + 'total_views' => 0, + 'total_refunded' => 0, + 'attendees_registered' => 0, + ]); + + $this->db + ->shouldReceive('select') + ->once() + ->with( + Mockery::on(static fn (string $sql): bool => str_contains($sql, 'eods.event_occurrence_id = :occurrenceId') + && str_contains($sql, 'eods.event_id = :eventId')), + [ + 'startDate' => '2026-01-01', + 'endDate' => '2026-01-31', + 'occurrenceId' => 200, + 'eventId' => 10, + ], + ) + ->andReturn([]); + + $response = $this->service->getEventStats(new EventStatsRequestDTO( + event_id: 10, + start_date: '2026-01-01', + end_date: '2026-01-31', + occurrence_id: 200, + )); + + $this->assertSame(0, $response->total_orders); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Domain/Event/RecurrenceRuleExclusionServiceTest.php b/backend/tests/Unit/Services/Domain/Event/RecurrenceRuleExclusionServiceTest.php new file mode 100644 index 0000000000..19425574ac --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Event/RecurrenceRuleExclusionServiceTest.php @@ -0,0 +1,233 @@ +eventRepository = Mockery::mock(EventRepositoryInterface::class); + $this->service = new RecurrenceRuleExclusionService($this->eventRepository); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function test_add_exclusions_appends_datetime_in_event_timezone(): void + { + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getType')->andReturn(EventType::RECURRING->name); + $event->shouldReceive('getTimezone')->andReturn('UTC'); + $event->shouldReceive('getRecurrenceRule')->andReturn([ + 'frequency' => 'weekly', + 'excluded_occurrences' => [], + ]); + + $this->eventRepository->shouldReceive('findByIdLocked')->once()->with(1)->andReturn($event); + $this->eventRepository + ->shouldReceive('updateFromArray') + ->once() + ->with(1, [ + EventDomainObjectAbstract::RECURRENCE_RULE => [ + 'frequency' => 'weekly', + 'excluded_occurrences' => ['2026-06-15 10:00'], + ], + ]); + + $this->service->addExclusions(1, ['2026-06-15 10:00:00']); + + $this->assertTrue(true); + } + + public function test_add_exclusions_does_nothing_when_event_is_not_recurring(): void + { + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getType')->andReturn(EventType::SINGLE->name); + + $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event); + $this->eventRepository->shouldNotReceive('updateFromArray'); + + $this->service->addExclusions(1, ['2026-06-15 10:00:00']); + + $this->assertTrue(true); + } + + public function test_add_exclusions_skips_duplicates_and_does_not_write_when_no_changes(): void + { + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getType')->andReturn(EventType::RECURRING->name); + $event->shouldReceive('getTimezone')->andReturn('UTC'); + $event->shouldReceive('getRecurrenceRule')->andReturn([ + 'excluded_occurrences' => ['2026-06-15 10:00'], + ]); + + $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event); + $this->eventRepository->shouldNotReceive('updateFromArray'); + + $this->service->addExclusions(1, ['2026-06-15 10:00:00']); + + $this->assertTrue(true); + } + + public function test_add_exclusions_appends_to_existing_list(): void + { + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getType')->andReturn(EventType::RECURRING->name); + $event->shouldReceive('getTimezone')->andReturn('UTC'); + $event->shouldReceive('getRecurrenceRule')->andReturn([ + 'excluded_occurrences' => ['2026-06-15 10:00'], + ]); + + $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event); + $this->eventRepository + ->shouldReceive('updateFromArray') + ->once() + ->with(1, Mockery::on(fn ($attrs) => $attrs[EventDomainObjectAbstract::RECURRENCE_RULE]['excluded_occurrences'] + === ['2026-06-15 10:00', '2026-07-20 14:00'])); + + $this->service->addExclusions(1, ['2026-07-20 14:00:00']); + + $this->assertTrue(true); + } + + public function test_add_exclusions_handles_recurrence_rule_as_json_string(): void + { + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getType')->andReturn(EventType::RECURRING->name); + $event->shouldReceive('getTimezone')->andReturn('UTC'); + $event->shouldReceive('getRecurrenceRule')->andReturn( + json_encode(['frequency' => 'daily', 'excluded_occurrences' => []]) + ); + + $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event); + $this->eventRepository + ->shouldReceive('updateFromArray') + ->once() + ->with(1, Mockery::on(fn ($attrs) => $attrs[EventDomainObjectAbstract::RECURRENCE_RULE]['excluded_occurrences'] + === ['2026-08-01 09:00'])); + + $this->service->addExclusions(1, ['2026-08-01 09:00:00']); + + $this->assertTrue(true); + } + + public function test_add_exclusions_deduplicates_input(): void + { + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getType')->andReturn(EventType::RECURRING->name); + $event->shouldReceive('getTimezone')->andReturn('UTC'); + $event->shouldReceive('getRecurrenceRule')->andReturn([ + 'excluded_occurrences' => [], + ]); + + $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event); + $this->eventRepository + ->shouldReceive('updateFromArray') + ->once() + ->with(1, Mockery::on(fn ($attrs) => $attrs[EventDomainObjectAbstract::RECURRENCE_RULE]['excluded_occurrences'] + === ['2026-06-15 10:00'])); + + $this->service->addExclusions(1, ['2026-06-15 10:00:00', '2026-06-15 10:00:00']); + + $this->assertTrue(true); + } + + public function test_remove_exclusion_removes_from_excluded_dates(): void + { + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getType')->andReturn(EventType::RECURRING->name); + $event->shouldReceive('getTimezone')->andReturn('UTC'); + $event->shouldReceive('getRecurrenceRule')->andReturn([ + 'excluded_dates' => ['2026-05-15', '2026-06-01', '2026-07-04'], + ]); + + $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event); + $this->eventRepository + ->shouldReceive('updateFromArray') + ->once() + ->with(1, Mockery::on(static function (array $attrs): bool { + $rule = $attrs[EventDomainObjectAbstract::RECURRENCE_RULE] ?? null; + + return is_array($rule) + && $rule['excluded_dates'] === ['2026-05-15', '2026-07-04']; + })); + + $this->service->removeExclusion(1, '2026-06-01 10:00:00'); + + $this->assertTrue(true); + } + + public function test_remove_exclusion_removes_from_excluded_occurrences(): void + { + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getType')->andReturn(EventType::RECURRING->name); + $event->shouldReceive('getTimezone')->andReturn('UTC'); + $event->shouldReceive('getRecurrenceRule')->andReturn([ + 'excluded_occurrences' => ['2026-06-01 10:00', '2026-07-20 14:00'], + ]); + + $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event); + $this->eventRepository + ->shouldReceive('updateFromArray') + ->once() + ->with(1, Mockery::on(static function (array $attrs): bool { + $rule = $attrs[EventDomainObjectAbstract::RECURRENCE_RULE] ?? null; + + return is_array($rule) + && $rule['excluded_occurrences'] === ['2026-07-20 14:00']; + })); + + $this->service->removeExclusion(1, '2026-06-01 10:00:00'); + + $this->assertTrue(true); + } + + public function test_remove_exclusion_does_nothing_when_date_is_not_excluded(): void + { + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getType')->andReturn(EventType::RECURRING->name); + $event->shouldReceive('getTimezone')->andReturn('UTC'); + $event->shouldReceive('getRecurrenceRule')->andReturn([ + 'excluded_dates' => ['2026-05-15'], + 'excluded_occurrences' => ['2026-07-20 14:00'], + ]); + + $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event); + $this->eventRepository->shouldNotReceive('updateFromArray'); + + $this->service->removeExclusion(1, '2026-06-01 10:00:00'); + + $this->assertTrue(true); + } + + public function test_remove_exclusion_does_nothing_when_event_is_not_recurring(): void + { + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getType')->andReturn(EventType::SINGLE->name); + + $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event); + $this->eventRepository->shouldNotReceive('updateFromArray'); + + $this->service->removeExclusion(1, '2026-06-01 10:00:00'); + + $this->assertTrue(true); + } +} diff --git a/backend/tests/Unit/Services/Domain/Event/RecurrenceRuleParserServiceTest.php b/backend/tests/Unit/Services/Domain/Event/RecurrenceRuleParserServiceTest.php new file mode 100644 index 0000000000..c43842a8da --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Event/RecurrenceRuleParserServiceTest.php @@ -0,0 +1,1472 @@ +service = new RecurrenceRuleParserService; + } + + // ─── Daily Frequency ─────────────────────────────────────────────── + + public function test_daily_frequency_generates_correct_dates(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['10:00'], + 'range' => [ + 'type' => 'count', + 'count' => 5, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(5, $result); + $this->assertEquals('2025-03-01', $result[0]['start']->format('Y-m-d')); + $this->assertEquals('2025-03-02', $result[1]['start']->format('Y-m-d')); + $this->assertEquals('2025-03-03', $result[2]['start']->format('Y-m-d')); + $this->assertEquals('2025-03-04', $result[3]['start']->format('Y-m-d')); + $this->assertEquals('2025-03-05', $result[4]['start']->format('Y-m-d')); + } + + public function test_daily_frequency_respects_interval(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 3, + 'times_of_day' => ['09:00'], + 'range' => [ + 'type' => 'count', + 'count' => 4, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(4, $result); + $this->assertEquals('2025-03-01', $result[0]['start']->format('Y-m-d')); + $this->assertEquals('2025-03-04', $result[1]['start']->format('Y-m-d')); + $this->assertEquals('2025-03-07', $result[2]['start']->format('Y-m-d')); + $this->assertEquals('2025-03-10', $result[3]['start']->format('Y-m-d')); + } + + // ─── Weekly Frequency ────────────────────────────────────────────── + + public function test_weekly_frequency_generates_correct_dates(): void + { + $rule = [ + 'frequency' => 'weekly', + 'interval' => 1, + 'days_of_week' => ['monday', 'wednesday', 'friday'], + 'times_of_day' => ['18:00'], + 'range' => [ + 'type' => 'count', + 'count' => 6, + 'start' => '2025-03-03', // Monday + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(6, $result); + $this->assertEquals('2025-03-03', $result[0]['start']->format('Y-m-d')); // Mon + $this->assertEquals('2025-03-05', $result[1]['start']->format('Y-m-d')); // Wed + $this->assertEquals('2025-03-07', $result[2]['start']->format('Y-m-d')); // Fri + $this->assertEquals('2025-03-10', $result[3]['start']->format('Y-m-d')); // Mon + $this->assertEquals('2025-03-12', $result[4]['start']->format('Y-m-d')); // Wed + $this->assertEquals('2025-03-14', $result[5]['start']->format('Y-m-d')); // Fri + } + + public function test_weekly_frequency_with_specific_days_of_week(): void + { + $rule = [ + 'frequency' => 'weekly', + 'interval' => 1, + 'days_of_week' => ['tuesday', 'thursday'], + 'times_of_day' => ['12:00'], + 'range' => [ + 'type' => 'count', + 'count' => 4, + 'start' => '2025-03-04', // Tuesday + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(4, $result); + $this->assertEquals('Tuesday', $result[0]['start']->format('l')); + $this->assertEquals('Thursday', $result[1]['start']->format('l')); + $this->assertEquals('Tuesday', $result[2]['start']->format('l')); + $this->assertEquals('Thursday', $result[3]['start']->format('l')); + } + + public function test_weekly_frequency_every_two_weeks(): void + { + $rule = [ + 'frequency' => 'weekly', + 'interval' => 2, + 'days_of_week' => ['monday'], + 'times_of_day' => ['10:00'], + 'range' => [ + 'type' => 'count', + 'count' => 3, + 'start' => '2025-03-03', // Monday + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(3, $result); + $this->assertEquals('2025-03-03', $result[0]['start']->format('Y-m-d')); + $this->assertEquals('2025-03-17', $result[1]['start']->format('Y-m-d')); + $this->assertEquals('2025-03-31', $result[2]['start']->format('Y-m-d')); + } + + public function test_weekly_frequency_with_empty_days_of_week_returns_empty(): void + { + $rule = [ + 'frequency' => 'weekly', + 'interval' => 1, + 'days_of_week' => [], + 'times_of_day' => ['10:00'], + 'range' => [ + 'type' => 'count', + 'count' => 5, + 'start' => '2025-03-03', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(0, $result); + } + + // ─── Monthly by Day of Month ─────────────────────────────────────── + + public function test_monthly_by_day_of_month_generates_correct_dates(): void + { + $rule = [ + 'frequency' => 'monthly', + 'interval' => 1, + 'monthly_pattern' => 'by_day_of_month', + 'days_of_month' => [15], + 'times_of_day' => ['14:00'], + 'range' => [ + 'type' => 'count', + 'count' => 4, + 'start' => '2025-01-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(4, $result); + $this->assertEquals('2025-01-15', $result[0]['start']->format('Y-m-d')); + $this->assertEquals('2025-02-15', $result[1]['start']->format('Y-m-d')); + $this->assertEquals('2025-03-15', $result[2]['start']->format('Y-m-d')); + $this->assertEquals('2025-04-15', $result[3]['start']->format('Y-m-d')); + } + + public function test_monthly_by_day_of_month_skips_days_that_dont_exist(): void + { + $rule = [ + 'frequency' => 'monthly', + 'interval' => 1, + 'monthly_pattern' => 'by_day_of_month', + 'days_of_month' => [31], + 'times_of_day' => ['10:00'], + 'range' => [ + 'type' => 'count', + 'count' => 5, + 'start' => '2025-01-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $dates = $result->pluck('start')->map(fn ($d) => $d->format('Y-m-d'))->toArray(); + + $this->assertContains('2025-01-31', $dates); + $this->assertContains('2025-03-31', $dates); + // February has no 31st, so it should be skipped + $this->assertNotContains('2025-02-31', $dates); + } + + public function test_monthly_by_day_of_month_with_multiple_days(): void + { + $rule = [ + 'frequency' => 'monthly', + 'interval' => 1, + 'monthly_pattern' => 'by_day_of_month', + 'days_of_month' => [1, 15], + 'times_of_day' => ['10:00'], + 'range' => [ + 'type' => 'count', + 'count' => 4, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(4, $result); + $this->assertEquals('2025-03-01', $result[0]['start']->format('Y-m-d')); + $this->assertEquals('2025-03-15', $result[1]['start']->format('Y-m-d')); + $this->assertEquals('2025-04-01', $result[2]['start']->format('Y-m-d')); + $this->assertEquals('2025-04-15', $result[3]['start']->format('Y-m-d')); + } + + // ─── Monthly by Day of Week with Week Position ───────────────────── + + public function test_monthly_by_day_of_week_first_monday(): void + { + $rule = [ + 'frequency' => 'monthly', + 'interval' => 1, + 'monthly_pattern' => 'by_day_of_week', + 'day_of_week' => 'monday', + 'week_position' => 1, + 'times_of_day' => ['19:00'], + 'range' => [ + 'type' => 'count', + 'count' => 3, + 'start' => '2025-01-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(3, $result); + // First Monday of Jan 2025 = Jan 6 + $this->assertEquals('2025-01-06', $result[0]['start']->format('Y-m-d')); + // First Monday of Feb 2025 = Feb 3 + $this->assertEquals('2025-02-03', $result[1]['start']->format('Y-m-d')); + // First Monday of Mar 2025 = Mar 3 + $this->assertEquals('2025-03-03', $result[2]['start']->format('Y-m-d')); + + foreach ($result as $occurrence) { + $this->assertEquals('Monday', $occurrence['start']->format('l')); + } + } + + public function test_monthly_by_day_of_week_last_friday(): void + { + $rule = [ + 'frequency' => 'monthly', + 'interval' => 1, + 'monthly_pattern' => 'by_day_of_week', + 'day_of_week' => 'friday', + 'week_position' => -1, + 'times_of_day' => ['17:00'], + 'range' => [ + 'type' => 'count', + 'count' => 3, + 'start' => '2025-01-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(3, $result); + // Last Friday of Jan 2025 = Jan 31 + $this->assertEquals('2025-01-31', $result[0]['start']->format('Y-m-d')); + // Last Friday of Feb 2025 = Feb 28 + $this->assertEquals('2025-02-28', $result[1]['start']->format('Y-m-d')); + // Last Friday of Mar 2025 = Mar 28 + $this->assertEquals('2025-03-28', $result[2]['start']->format('Y-m-d')); + + foreach ($result as $occurrence) { + $this->assertEquals('Friday', $occurrence['start']->format('l')); + } + } + + public function test_monthly_by_day_of_week_third_wednesday(): void + { + $rule = [ + 'frequency' => 'monthly', + 'interval' => 1, + 'monthly_pattern' => 'by_day_of_week', + 'day_of_week' => 'wednesday', + 'week_position' => 3, + 'times_of_day' => ['10:00'], + 'range' => [ + 'type' => 'count', + 'count' => 3, + 'start' => '2025-01-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(3, $result); + // Third Wednesday of Jan 2025 = Jan 15 + $this->assertEquals('2025-01-15', $result[0]['start']->format('Y-m-d')); + // Third Wednesday of Feb 2025 = Feb 19 + $this->assertEquals('2025-02-19', $result[1]['start']->format('Y-m-d')); + // Third Wednesday of Mar 2025 = Mar 19 + $this->assertEquals('2025-03-19', $result[2]['start']->format('Y-m-d')); + + foreach ($result as $occurrence) { + $this->assertEquals('Wednesday', $occurrence['start']->format('l')); + } + } + + // ─── Yearly Frequency ────────────────────────────────────────────── + + public function test_yearly_frequency_generates_correct_dates(): void + { + $rule = [ + 'frequency' => 'yearly', + 'interval' => 1, + 'times_of_day' => ['12:00'], + 'range' => [ + 'type' => 'count', + 'count' => 4, + 'start' => '2025-06-15', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(4, $result); + $this->assertEquals('2025-06-15', $result[0]['start']->format('Y-m-d')); + $this->assertEquals('2026-06-15', $result[1]['start']->format('Y-m-d')); + $this->assertEquals('2027-06-15', $result[2]['start']->format('Y-m-d')); + $this->assertEquals('2028-06-15', $result[3]['start']->format('Y-m-d')); + } + + public function test_yearly_frequency_every_two_years(): void + { + $rule = [ + 'frequency' => 'yearly', + 'interval' => 2, + 'times_of_day' => ['08:00'], + 'range' => [ + 'type' => 'count', + 'count' => 3, + 'start' => '2025-01-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(3, $result); + $this->assertEquals('2025-01-01', $result[0]['start']->format('Y-m-d')); + $this->assertEquals('2027-01-01', $result[1]['start']->format('Y-m-d')); + $this->assertEquals('2029-01-01', $result[2]['start']->format('Y-m-d')); + } + + // ─── Interval ────────────────────────────────────────────────────── + + public function test_every_three_months_interval(): void + { + $rule = [ + 'frequency' => 'monthly', + 'interval' => 3, + 'monthly_pattern' => 'by_day_of_month', + 'days_of_month' => [1], + 'times_of_day' => ['09:00'], + 'range' => [ + 'type' => 'count', + 'count' => 4, + 'start' => '2025-01-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(4, $result); + $this->assertEquals('2025-01-01', $result[0]['start']->format('Y-m-d')); + $this->assertEquals('2025-04-01', $result[1]['start']->format('Y-m-d')); + $this->assertEquals('2025-07-01', $result[2]['start']->format('Y-m-d')); + $this->assertEquals('2025-10-01', $result[3]['start']->format('Y-m-d')); + } + + // ─── Times of Day ────────────────────────────────────────────────── + + public function test_multiple_times_of_day_generates_multiple_occurrences(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['09:00', '14:00', '19:00'], + 'range' => [ + 'type' => 'count', + 'count' => 2, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + // 2 calendar dates * 3 times of day = 6 occurrences + $this->assertCount(6, $result); + + // Day 1: three times + $this->assertEquals('2025-03-01 09:00', $result[0]['start']->format('Y-m-d H:i')); + $this->assertEquals('2025-03-01 14:00', $result[1]['start']->format('Y-m-d H:i')); + $this->assertEquals('2025-03-01 19:00', $result[2]['start']->format('Y-m-d H:i')); + + // Day 2: three times + $this->assertEquals('2025-03-02 09:00', $result[3]['start']->format('Y-m-d H:i')); + $this->assertEquals('2025-03-02 14:00', $result[4]['start']->format('Y-m-d H:i')); + $this->assertEquals('2025-03-02 19:00', $result[5]['start']->format('Y-m-d H:i')); + } + + public function test_times_of_day_defaults_to_midnight(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'range' => [ + 'type' => 'count', + 'count' => 2, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(2, $result); + $this->assertEquals('00:00', $result[0]['start']->format('H:i')); + $this->assertEquals('00:00', $result[1]['start']->format('H:i')); + } + + // ─── Duration Minutes ────────────────────────────────────────────── + + public function test_duration_minutes_sets_end_date(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['10:00'], + 'duration_minutes' => 90, + 'range' => [ + 'type' => 'count', + 'count' => 2, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(2, $result); + $this->assertEquals('2025-03-01 10:00', $result[0]['start']->format('Y-m-d H:i')); + $this->assertEquals('2025-03-01 11:30', $result[0]['end']->format('Y-m-d H:i')); + $this->assertEquals('2025-03-02 10:00', $result[1]['start']->format('Y-m-d H:i')); + $this->assertEquals('2025-03-02 11:30', $result[1]['end']->format('Y-m-d H:i')); + } + + public function test_no_duration_minutes_leaves_end_date_null(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['10:00'], + 'range' => [ + 'type' => 'count', + 'count' => 1, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(1, $result); + $this->assertNull($result[0]['end']); + } + + // ─── Count Limit ─────────────────────────────────────────────────── + + public function test_count_limit_stops_after_n_occurrences(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['10:00'], + 'range' => [ + 'type' => 'count', + 'count' => 7, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(7, $result); + } + + public function test_count_limit_with_multiple_times_per_day(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['09:00', '18:00'], + 'range' => [ + 'type' => 'count', + 'count' => 5, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + // range.count is the number of calendar dates. With 2 times/day, that yields 5 * 2 = 10 occurrences. + $this->assertCount(10, $result); + $this->assertEquals('2025-03-01 09:00', $result[0]['start']->format('Y-m-d H:i')); + $this->assertEquals('2025-03-01 18:00', $result[1]['start']->format('Y-m-d H:i')); + $this->assertEquals('2025-03-05 09:00', $result[8]['start']->format('Y-m-d H:i')); + $this->assertEquals('2025-03-05 18:00', $result[9]['start']->format('Y-m-d H:i')); + } + + public function test_count_limit_with_multiple_times_per_day_weekly(): void + { + $rule = [ + 'frequency' => 'weekly', + 'interval' => 1, + 'days_of_week' => ['monday', 'wednesday', 'friday'], + 'times_of_day' => ['09:00', '18:00'], + 'range' => [ + 'type' => 'count', + 'count' => 4, + 'start' => '2025-03-03', // Monday + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + // 4 calendar dates * 2 sessions per day = 8 occurrences total. + $this->assertCount(8, $result); + } + + public function test_count_limit_with_multiple_times_per_day_monthly(): void + { + $rule = [ + 'frequency' => 'monthly', + 'interval' => 1, + 'monthly_pattern' => 'by_day_of_month', + 'days_of_month' => [1, 15], + 'times_of_day' => ['09:00', '18:00'], + 'range' => [ + 'type' => 'count', + 'count' => 4, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + // 4 calendar dates * 2 sessions per day = 8 occurrences total. + $this->assertCount(8, $result); + } + + public function test_default_count_is_ten_when_not_specified(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['10:00'], + 'range' => [ + 'type' => 'count', + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(10, $result); + } + + // ─── Until Date ──────────────────────────────────────────────────── + + public function test_until_date_stops_at_specified_date(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['10:00'], + 'range' => [ + 'type' => 'until', + 'until' => '2025-03-05', + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(5, $result); + $this->assertEquals('2025-03-01', $result[0]['start']->format('Y-m-d')); + $this->assertEquals('2025-03-05', $result[4]['start']->format('Y-m-d')); + } + + public function test_until_date_with_weekly_frequency(): void + { + $rule = [ + 'frequency' => 'weekly', + 'interval' => 1, + 'days_of_week' => ['wednesday'], + 'times_of_day' => ['15:00'], + 'range' => [ + 'type' => 'until', + 'until' => '2025-03-20', + 'start' => '2025-03-05', // Wednesday + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(3, $result); + $this->assertEquals('2025-03-05', $result[0]['start']->format('Y-m-d')); + $this->assertEquals('2025-03-12', $result[1]['start']->format('Y-m-d')); + $this->assertEquals('2025-03-19', $result[2]['start']->format('Y-m-d')); + } + + // ─── Excluded Dates ──────────────────────────────────────────────── + + public function test_excluded_dates_are_skipped(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['10:00'], + 'excluded_dates' => ['2025-03-03', '2025-03-05'], + 'range' => [ + 'type' => 'count', + 'count' => 7, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $dates = $result->pluck('start')->map(fn ($d) => $d->format('Y-m-d'))->toArray(); + + $this->assertNotContains('2025-03-03', $dates); + $this->assertNotContains('2025-03-05', $dates); + $this->assertContains('2025-03-01', $dates); + $this->assertContains('2025-03-02', $dates); + $this->assertContains('2025-03-04', $dates); + } + + public function test_excluded_dates_with_multiple_times_per_day(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['09:00', '18:00'], + 'excluded_dates' => ['2025-03-02'], + 'range' => [ + 'type' => 'count', + 'count' => 6, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $dates = $result->pluck('start')->map(fn ($d) => $d->format('Y-m-d'))->toArray(); + + $this->assertNotContains('2025-03-02', $dates); + } + + public function test_excluded_occurrences_skip_only_matching_time_slot(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['09:00', '18:00'], + 'excluded_occurrences' => ['2025-03-02 09:00'], + 'range' => [ + 'type' => 'count', + 'count' => 6, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $starts = $result->pluck('start')->map(fn ($d) => $d->format('Y-m-d H:i'))->toArray(); + + $this->assertNotContains('2025-03-02 09:00', $starts); + $this->assertContains('2025-03-02 18:00', $starts); + } + + // ─── Additional Dates ────────────────────────────────────────────── + + public function test_additional_dates_are_included(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['10:00'], + 'additional_dates' => [ + ['date' => '2025-04-01', 'time' => '20:00'], + ['date' => '2025-05-15', 'time' => '11:00'], + ], + 'range' => [ + 'type' => 'count', + 'count' => 2, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + // 2 from daily + 2 additional = 4 + $this->assertCount(4, $result); + + $dates = $result->pluck('start')->map(fn ($d) => $d->format('Y-m-d'))->toArray(); + $this->assertContains('2025-04-01', $dates); + $this->assertContains('2025-05-15', $dates); + } + + public function test_additional_dates_are_sorted_with_regular_dates(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['10:00'], + 'additional_dates' => [ + ['date' => '2025-03-02', 'time' => '08:00'], + ], + 'range' => [ + 'type' => 'count', + 'count' => 3, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + // Result should be sorted by start time + for ($i = 1; $i < $result->count(); $i++) { + $this->assertTrue( + $result[$i]['start']->greaterThanOrEqualTo($result[$i - 1]['start']), + 'Results should be sorted by start date' + ); + } + } + + public function test_additional_dates_default_time_to_midnight(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['10:00'], + 'additional_dates' => [ + ['date' => '2025-06-01'], + ], + 'range' => [ + 'type' => 'count', + 'count' => 1, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $additionalOccurrence = $result->first(fn ($o) => $o['start']->format('Y-m-d') === '2025-06-01'); + $this->assertNotNull($additionalOccurrence); + $this->assertEquals('00:00', $additionalOccurrence['start']->format('H:i')); + } + + // ─── DST Transition Handling ─────────────────────────────────────── + + public function test_dst_spring_forward_transition(): void + { + // 2025 DST spring forward in America/New_York: March 9 at 2:00 AM + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['10:00'], + 'duration_minutes' => 60, + 'range' => [ + 'type' => 'count', + 'count' => 3, + 'start' => '2025-03-08', + ], + ]; + + $result = $this->service->parse($rule, 'America/New_York'); + + $this->assertCount(3, $result); + + // All start times should be at 10:00 local time, converted to UTC + // Before DST (EST = UTC-5): Mar 8 10:00 EST = 15:00 UTC + $this->assertEquals('15:00', $result[0]['start']->format('H:i')); + + // After DST (EDT = UTC-4): Mar 9 10:00 EDT = 14:00 UTC + $this->assertEquals('14:00', $result[1]['start']->format('H:i')); + + // After DST (EDT = UTC-4): Mar 10 10:00 EDT = 14:00 UTC + $this->assertEquals('14:00', $result[2]['start']->format('H:i')); + } + + public function test_weekly_rule_keeps_local_time_across_dst_spring_forward(): void + { + // A weekly class at 10:00 on Wednesdays in America/New_York, spanning the + // 2025 spring-forward (March 9). The attendee should always see 10:00 local. + $rule = [ + 'frequency' => 'weekly', + 'interval' => 1, + 'days_of_week' => ['wednesday'], + 'times_of_day' => ['10:00'], + 'range' => [ + 'type' => 'count', + 'count' => 3, + 'start' => '2025-03-05', + ], + ]; + + $result = $this->service->parse($rule, 'America/New_York'); + + $this->assertCount(3, $result); + + // Wed Mar 5 (EST, UTC-5): 10:00 local → 15:00 UTC + $this->assertSame('2025-03-05 15:00', $result[0]['start']->format('Y-m-d H:i')); + // Wed Mar 12 (EDT, UTC-4): 10:00 local → 14:00 UTC + $this->assertSame('2025-03-12 14:00', $result[1]['start']->format('Y-m-d H:i')); + // Wed Mar 19 (EDT, UTC-4): 10:00 local → 14:00 UTC + $this->assertSame('2025-03-19 14:00', $result[2]['start']->format('Y-m-d H:i')); + + foreach ($result as $slot) { + $this->assertSame('10:00', $slot['start']->setTimezone('America/New_York')->format('H:i')); + } + } + + public function test_monthly_rule_keeps_local_time_across_dst_fall_back(): void + { + // Monthly on the 1st at 09:00 America/New_York: spans fall-back (Nov 2 2025). + $rule = [ + 'frequency' => 'monthly', + 'interval' => 1, + 'monthly_pattern' => 'by_day_of_month', + 'days_of_month' => [1], + 'times_of_day' => ['09:00'], + 'range' => [ + 'type' => 'count', + 'count' => 3, + 'start' => '2025-10-01', + ], + ]; + + $result = $this->service->parse($rule, 'America/New_York'); + + $this->assertCount(3, $result); + + foreach ($result as $slot) { + $this->assertSame('09:00', $slot['start']->setTimezone('America/New_York')->format('H:i')); + } + + // Oct 1 before fall-back (EDT = UTC-4): 09:00 local → 13:00 UTC + $this->assertSame('13:00', $result[0]['start']->format('H:i')); + // Nov 1 still EDT (fall-back happens on Nov 2): 09:00 local → 13:00 UTC + $this->assertSame('13:00', $result[1]['start']->format('H:i')); + // Dec 1 after fall-back (EST = UTC-5): 09:00 local → 14:00 UTC + $this->assertSame('14:00', $result[2]['start']->format('H:i')); + } + + public function test_daily_rule_at_skipped_hour_during_spring_forward_does_not_produce_invalid_time(): void + { + // In America/New_York on Mar 9 2025, 02:30 local time does not exist (clocks + // jump from 02:00 EST straight to 03:00 EDT). Parser should not emit an + // occurrence whose local time still reads 02:30 on the skipped day — Carbon + // rolls it forward to 03:30 EDT = 07:30 UTC. + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['02:30'], + 'range' => [ + 'type' => 'count', + 'count' => 3, + 'start' => '2025-03-08', + ], + ]; + + $result = $this->service->parse($rule, 'America/New_York'); + + $this->assertCount(3, $result); + + // Mar 8 (EST = UTC-5): 02:30 local → 07:30 UTC + $this->assertSame('2025-03-08 07:30', $result[0]['start']->format('Y-m-d H:i')); + // Mar 9 skipped hour: Carbon rolls the non-existent 02:30 forward to 03:30 EDT + // (the first valid minute after the clock jumps), which maps to 07:30 UTC. The + // concrete assertion catches regressions that would leave it at 02:30 EDT + // (06:30 UTC) or 02:30 EST (07:30 UTC but with wrong local reading). + $this->assertSame('2025-03-09 07:30', $result[1]['start']->format('Y-m-d H:i')); + $this->assertSame('03:30', $result[1]['start']->setTimezone('America/New_York')->format('H:i')); + // Mar 10 (EDT = UTC-4): 02:30 local → 06:30 UTC + $this->assertSame('2025-03-10 06:30', $result[2]['start']->format('Y-m-d H:i')); + } + + public function test_dst_fall_back_transition(): void + { + // 2025 DST fall back in America/New_York: November 2 at 2:00 AM + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['10:00'], + 'range' => [ + 'type' => 'count', + 'count' => 3, + 'start' => '2025-11-01', + ], + ]; + + $result = $this->service->parse($rule, 'America/New_York'); + + $this->assertCount(3, $result); + + // Before DST ends (EDT = UTC-4): Nov 1 10:00 EDT = 14:00 UTC + $this->assertEquals('14:00', $result[0]['start']->format('H:i')); + + // After DST ends (EST = UTC-5): Nov 2 10:00 EST = 15:00 UTC + $this->assertEquals('15:00', $result[1]['start']->format('H:i')); + + // After DST ends (EST = UTC-5): Nov 3 10:00 EST = 15:00 UTC + $this->assertEquals('15:00', $result[2]['start']->format('H:i')); + } + + // ─── Timezone Conversion ─────────────────────────────────────────── + + public function test_timezone_conversion_to_utc(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['20:00'], + 'range' => [ + 'type' => 'count', + 'count' => 1, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'Europe/Berlin'); + + // Europe/Berlin is UTC+1 in winter (CET) + // 20:00 CET = 19:00 UTC + $this->assertEquals('19:00', $result[0]['start']->format('H:i')); + $this->assertEquals('UTC', $result[0]['start']->timezone->getName()); + } + + // ─── Cap at 1200 Occurrences ─────────────────────────────────────── + + public function test_cap_at1200_occurrences(): void + { + // Parser is allowed to push one candidate beyond MAX so the handler's + // `count > MAX` overflow check can fire. The user-visible ceiling is + // still MAX — this just lets the overflow be detectable instead of + // silently truncated. + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['10:00'], + 'range' => [ + 'type' => 'until', + 'until' => '2030-01-01', + 'start' => '2025-01-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertLessThanOrEqual(\HiEvents\Services\Domain\Event\RecurrenceRuleParserService::MAX_OCCURRENCES + 1, $result->count()); + } + + public function test_overflow_pushes_one_extra_candidate_for_handler_detection(): void + { + // Daily 4 times/day across ~5 years = 7305 candidates. Parser must + // surface this as MAX+1 rather than silently capping at MAX so the + // handler can return a 422. + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['08:00', '12:00', '16:00', '20:00'], + 'range' => [ + 'type' => 'until', + 'until' => '2030-01-01', + 'start' => '2025-01-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertSame( + \HiEvents\Services\Domain\Event\RecurrenceRuleParserService::MAX_OCCURRENCES + 1, + $result->count(), + ); + } + + // ─── Default Capacity ────────────────────────────────────────────── + + public function test_default_capacity_is_included_in_results(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['10:00'], + 'default_capacity' => 100, + 'range' => [ + 'type' => 'count', + 'count' => 2, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(2, $result); + $this->assertEquals(100, $result[0]['capacity']); + $this->assertEquals(100, $result[1]['capacity']); + } + + public function test_default_capacity_is_null_when_not_specified(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['10:00'], + 'range' => [ + 'type' => 'count', + 'count' => 1, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertNull($result[0]['capacity']); + } + + // ─── Unknown Frequency ───────────────────────────────────────────── + + public function test_unknown_frequency_throws(): void + { + $rule = [ + 'frequency' => 'unknown', + 'interval' => 1, + 'times_of_day' => ['10:00'], + 'range' => [ + 'type' => 'count', + 'count' => 5, + 'start' => '2025-03-01', + ], + ]; + + $this->expectException(\HiEvents\Exceptions\InvalidRecurrenceRuleException::class); + $this->expectExceptionMessage('Unsupported recurrence frequency'); + + $this->service->parse($rule, 'UTC'); + } + + // ─── Result Structure ────────────────────────────────────────────── + + public function test_result_contains_expected_keys(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['10:00'], + 'duration_minutes' => 60, + 'default_capacity' => 50, + 'range' => [ + 'type' => 'count', + 'count' => 1, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertArrayHasKey('start', $result[0]); + $this->assertArrayHasKey('end', $result[0]); + $this->assertArrayHasKey('capacity', $result[0]); + $this->assertInstanceOf(CarbonImmutable::class, $result[0]['start']); + $this->assertInstanceOf(CarbonImmutable::class, $result[0]['end']); + } + + public function test_results_are_returned_as_collection(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['10:00'], + 'range' => [ + 'type' => 'count', + 'count' => 3, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); + } + + // ─── Edge Cases ──────────────────────────────────────────────────── + + public function test_additional_dates_respect_duration_minutes(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['10:00'], + 'duration_minutes' => 120, + 'additional_dates' => [ + ['date' => '2025-06-01', 'time' => '14:00'], + ], + 'range' => [ + 'type' => 'count', + 'count' => 1, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $additionalOccurrence = $result->first(fn ($o) => $o['start']->format('Y-m-d') === '2025-06-01'); + $this->assertNotNull($additionalOccurrence); + $this->assertEquals('14:00', $additionalOccurrence['start']->format('H:i')); + $this->assertEquals('16:00', $additionalOccurrence['end']->format('H:i')); + } + + public function test_additional_dates_cap_at1200_total(): void + { + $additionalDates = []; + for ($i = 0; $i < 1500; $i++) { + $date = CarbonImmutable::parse('2030-01-01')->addDays($i); + $additionalDates[] = ['date' => $date->format('Y-m-d'), 'time' => '10:00']; + } + + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['10:00'], + 'additional_dates' => $additionalDates, + 'range' => [ + 'type' => 'count', + 'count' => 100, + 'start' => '2025-01-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertLessThanOrEqual(\HiEvents\Services\Domain\Event\RecurrenceRuleParserService::MAX_OCCURRENCES + 1, $result->count()); + } + + public function test_monthly_by_day_of_week_every_two_months(): void + { + $rule = [ + 'frequency' => 'monthly', + 'interval' => 2, + 'monthly_pattern' => 'by_day_of_week', + 'day_of_week' => 'tuesday', + 'week_position' => 2, + 'times_of_day' => ['10:00'], + 'range' => [ + 'type' => 'count', + 'count' => 3, + 'start' => '2025-01-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(3, $result); + // Second Tuesday of Jan 2025 = Jan 14 + $this->assertEquals('2025-01-14', $result[0]['start']->format('Y-m-d')); + // Second Tuesday of Mar 2025 = Mar 11 + $this->assertEquals('2025-03-11', $result[1]['start']->format('Y-m-d')); + // Second Tuesday of May 2025 = May 13 + $this->assertEquals('2025-05-13', $result[2]['start']->format('Y-m-d')); + + foreach ($result as $occurrence) { + $this->assertEquals('Tuesday', $occurrence['start']->format('l')); + } + } + + public function test_daily_with_excluded_dates_still_produces_correct_count(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['10:00'], + 'excluded_dates' => ['2025-03-02', '2025-03-04'], + 'range' => [ + 'type' => 'count', + 'count' => 5, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + // The count controls how many dates are generated, not the final count after exclusions + // 5 dates generated (Mar 1-5), 2 excluded (Mar 2, 4), so 3 remain + $this->assertCount(3, $result); + $dates = $result->pluck('start')->map(fn ($d) => $d->format('Y-m-d'))->toArray(); + $this->assertEquals(['2025-03-01', '2025-03-03', '2025-03-05'], $dates); + } + + public function test_weekly_with_start_date_mid_week(): void + { + // Start date is a Thursday, but we want Monday and Friday events + $rule = [ + 'frequency' => 'weekly', + 'interval' => 1, + 'days_of_week' => ['monday', 'friday'], + 'times_of_day' => ['10:00'], + 'range' => [ + 'type' => 'count', + 'count' => 4, + 'start' => '2025-03-06', // Thursday + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(4, $result); + // First occurrence should be Friday Mar 7 (first matching day on/after start) + $this->assertEquals('2025-03-07', $result[0]['start']->format('Y-m-d')); + $this->assertEquals('Friday', $result[0]['start']->format('l')); + } + + // ─── Deduplication ───────────────────────────────────────────────── + + public function test_duplicate_additional_date_is_deduped(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['10:00'], + 'additional_dates' => [ + // Same start time as the rule's first occurrence + ['date' => '2025-03-01', 'time' => '10:00'], + ], + 'range' => [ + 'type' => 'count', + 'count' => 3, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(3, $result); + $startTimes = $result->pluck('start') + ->map(fn ($d) => $d->toDateTimeString()) + ->toArray(); + $this->assertEquals(count($startTimes), count(array_unique($startTimes))); + } + + public function test_duplicate_time_slots_are_deduped(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['10:00', '10:00', '14:00'], + 'range' => [ + 'type' => 'count', + 'count' => 2, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + // 2 dates * 2 unique times = 4 occurrences (the duplicate 10:00 is deduped) + $this->assertCount(4, $result); + $startTimes = $result->pluck('start') + ->map(fn ($d) => $d->toDateTimeString()) + ->toArray(); + $this->assertEquals(count($startTimes), count(array_unique($startTimes))); + } + + // ─── Validation ──────────────────────────────────────────────────── + + public function test_invalid_time_of_day_string_throws(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['25:00'], + 'range' => [ + 'type' => 'count', + 'count' => 2, + 'start' => '2025-03-01', + ], + ]; + + $this->expectException(\HiEvents\Exceptions\InvalidRecurrenceRuleException::class); + $this->service->parse($rule, 'UTC'); + } + + public function test_invalid_time_of_day_object_throws(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => [['time' => '99:99']], + 'range' => [ + 'type' => 'count', + 'count' => 1, + 'start' => '2025-03-01', + ], + ]; + + $this->expectException(\HiEvents\Exceptions\InvalidRecurrenceRuleException::class); + $this->service->parse($rule, 'UTC'); + } + + public function test_invalid_additional_date_time_throws(): void + { + $rule = [ + 'frequency' => 'daily', + 'interval' => 1, + 'times_of_day' => ['10:00'], + 'additional_dates' => [ + ['date' => '2025-04-01', 'time' => 'abc'], + ], + 'range' => [ + 'type' => 'count', + 'count' => 1, + 'start' => '2025-03-01', + ], + ]; + + $this->expectException(\HiEvents\Exceptions\InvalidRecurrenceRuleException::class); + $this->service->parse($rule, 'UTC'); + } + + public function test_weekly_rule_with_sunday_produces_sunday_dates(): void + { + // Carbon::SUNDAY is 0; the previous bare ->filter() dropped that, so a + // weekly Sunday-only rule silently emitted zero occurrences. + $rule = [ + 'frequency' => 'weekly', + 'interval' => 1, + 'days_of_week' => ['sunday'], + 'times_of_day' => ['10:00'], + 'range' => [ + 'type' => 'count', + 'count' => 3, + 'start' => '2025-03-03', // Monday + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(3, $result); + // First Sunday on/after Mon Mar 3 is Sun Mar 9. + $this->assertEquals('2025-03-09', $result[0]['start']->format('Y-m-d')); + $this->assertEquals('2025-03-16', $result[1]['start']->format('Y-m-d')); + $this->assertEquals('2025-03-23', $result[2]['start']->format('Y-m-d')); + foreach ($result as $occ) { + $this->assertEquals('Sunday', $occ['start']->format('l')); + } + } + + public function test_weekly_rule_with_sunday_mixed_with_other_days(): void + { + // Sunday + Wednesday: ensure both are emitted and ordered correctly + // after the final sort. + $rule = [ + 'frequency' => 'weekly', + 'interval' => 1, + 'days_of_week' => ['sunday', 'wednesday'], + 'times_of_day' => ['10:00'], + 'range' => [ + 'type' => 'count', + 'count' => 4, + 'start' => '2025-03-03', // Monday + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(4, $result); + // Wed Mar 5, Sun Mar 9, Wed Mar 12, Sun Mar 16 + $this->assertEquals('2025-03-05', $result[0]['start']->format('Y-m-d')); + $this->assertEquals('2025-03-09', $result[1]['start']->format('Y-m-d')); + $this->assertEquals('2025-03-12', $result[2]['start']->format('Y-m-d')); + $this->assertEquals('2025-03-16', $result[3]['start']->format('Y-m-d')); + } + + public function test_monthly_by_day_of_week_first_sunday(): void + { + // The previous code compared dayOfWeekIso (Sun = 7) to Carbon::SUNDAY + // (= 0), so the inner walk-the-days loop never matched on a Sunday and + // looped indefinitely. dayOfWeek (0..6) lines up with the constant. + $rule = [ + 'frequency' => 'monthly', + 'interval' => 1, + 'monthly_pattern' => 'by_day_of_week', + 'day_of_week' => 'sunday', + 'week_position' => 1, + 'times_of_day' => ['10:00'], + 'range' => [ + 'type' => 'count', + 'count' => 3, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(3, $result); + // First Sundays of Mar/Apr/May 2025: Mar 2, Apr 6, May 4. + $this->assertEquals('2025-03-02', $result[0]['start']->format('Y-m-d')); + $this->assertEquals('2025-04-06', $result[1]['start']->format('Y-m-d')); + $this->assertEquals('2025-05-04', $result[2]['start']->format('Y-m-d')); + foreach ($result as $occ) { + $this->assertEquals('Sunday', $occ['start']->format('l')); + } + } + + public function test_monthly_by_day_of_week_last_sunday(): void + { + // Last-Sunday rule exercised the -1 branch of getNthDayOfWeekInMonth, + // which had the same dayOfWeekIso bug walking backwards from end of + // month. + $rule = [ + 'frequency' => 'monthly', + 'interval' => 1, + 'monthly_pattern' => 'by_day_of_week', + 'day_of_week' => 'sunday', + 'week_position' => -1, + 'times_of_day' => ['10:00'], + 'range' => [ + 'type' => 'count', + 'count' => 3, + 'start' => '2025-03-01', + ], + ]; + + $result = $this->service->parse($rule, 'UTC'); + + $this->assertCount(3, $result); + // Last Sundays of Mar/Apr/May 2025: Mar 30, Apr 27, May 25. + $this->assertEquals('2025-03-30', $result[0]['start']->format('Y-m-d')); + $this->assertEquals('2025-04-27', $result[1]['start']->format('Y-m-d')); + $this->assertEquals('2025-05-25', $result[2]['start']->format('Y-m-d')); + foreach ($result as $occ) { + $this->assertEquals('Sunday', $occ['start']->format('l')); + } + } +} diff --git a/backend/tests/Unit/Services/Domain/EventOccurrence/CancelOccurrenceAttendeesServiceTest.php b/backend/tests/Unit/Services/Domain/EventOccurrence/CancelOccurrenceAttendeesServiceTest.php new file mode 100644 index 0000000000..b6f53b868b --- /dev/null +++ b/backend/tests/Unit/Services/Domain/EventOccurrence/CancelOccurrenceAttendeesServiceTest.php @@ -0,0 +1,300 @@ +attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class); + $this->productQuantityService = Mockery::mock(ProductQuantityUpdateService::class); + $this->domainEventDispatcherService = Mockery::mock(DomainEventDispatcherService::class); + $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class); + $this->statisticsCancellationService = Mockery::mock(EventStatisticsCancellationService::class); + $this->logger = Mockery::mock(LoggerInterface::class); + + // Default the logger to a permissive spy — only the failure path test + // overrides it with a strict expectation. + $this->logger->shouldReceive('error')->zeroOrMoreTimes()->byDefault(); + + // Default the stats service / order repo to no-op (orderRepository + // returns no orders → no decrement calls). Tests that exercise the + // happy path provide explicit expectations that take precedence. + $this->orderRepository + ->shouldReceive('findWhereIn') + ->zeroOrMoreTimes() + ->andReturn(new Collection) + ->byDefault(); + $this->statisticsCancellationService + ->shouldReceive('decrementForCancelledAttendee') + ->zeroOrMoreTimes() + ->byDefault(); + + $this->service = new CancelOccurrenceAttendeesService( + $this->attendeeRepository, + $this->productQuantityService, + $this->domainEventDispatcherService, + $this->orderRepository, + $this->statisticsCancellationService, + $this->logger, + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function test_cancels_active_attendees_and_decrements_quantities(): void + { + $eventId = 1; + $occurrenceId = 10; + + $attendeeA = $this->makeAttendee(id: 101, productId: 7, productPriceId: 70); + $attendeeB = $this->makeAttendee(id: 102, productId: 7, productPriceId: 70); + $attendeeC = $this->makeAttendee(id: 103, productId: 8, productPriceId: 80); + + $this->attendeeRepository + ->shouldReceive('findWhere') + ->with([ + AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrenceId, + [AttendeeDomainObjectAbstract::STATUS, 'in', [AttendeeStatus::ACTIVE->name, AttendeeStatus::AWAITING_PAYMENT->name]], + ]) + ->andReturn(new Collection([$attendeeA, $attendeeB, $attendeeC])); + + $this->attendeeRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + [AttendeeDomainObjectAbstract::STATUS => AttendeeStatus::CANCELLED->name], + [ + AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrenceId, + [AttendeeDomainObjectAbstract::STATUS, 'in', [AttendeeStatus::ACTIVE->name, AttendeeStatus::AWAITING_PAYMENT->name]], + ], + ); + + // Price 70 × 2 + price 80 × 1 = two grouped decrement calls. + $this->productQuantityService + ->shouldReceive('decreaseQuantitySold')->once()->with(70, 2, $occurrenceId); + $this->productQuantityService + ->shouldReceive('decreaseQuantitySold')->once()->with(80, 1, $occurrenceId); + + $this->domainEventDispatcherService + ->shouldReceive('dispatch') + ->times(3) + ->with(Mockery::on(fn (AttendeeEvent $e) => $e->type === DomainEventType::ATTENDEE_CANCELLED + && in_array($e->attendeeId, [101, 102, 103], true))); + + $this->service->cancelForOccurrence($eventId, $occurrenceId); + + Event::assertDispatched( + CapacityChangedEvent::class, + fn (CapacityChangedEvent $e) => $e->productId === 7 && $e->direction === CapacityChangeDirection::INCREASED, + ); + Event::assertDispatched( + CapacityChangedEvent::class, + fn (CapacityChangedEvent $e) => $e->productId === 8 && $e->direction === CapacityChangeDirection::INCREASED, + ); + } + + public function test_skips_everything_when_no_cancellable_attendees(): void + { + $this->attendeeRepository + ->shouldReceive('findWhere') + ->andReturn(new Collection); + + $this->attendeeRepository->shouldNotReceive('updateWhere'); + $this->productQuantityService->shouldNotReceive('decreaseQuantitySold'); + $this->domainEventDispatcherService->shouldNotReceive('dispatch'); + + $this->service->cancelForOccurrence(1, 10); + + Event::assertNotDispatched(CapacityChangedEvent::class); + } + + public function test_also_cancels_awaiting_payment_attendees(): void + { + // Offline-payment attendees occupy a seat but have status AWAITING_PAYMENT. + // They must be cancelled too when the occurrence is cancelled. + $attendee = $this->makeAttendee(id: 201, productId: 5, productPriceId: 50); + + $this->attendeeRepository + ->shouldReceive('findWhere') + ->with(Mockery::on(function (array $where) { + $statusClause = $where[0] ?? null; + + return is_array($statusClause) + && $statusClause[0] === AttendeeDomainObjectAbstract::STATUS + && $statusClause[1] === 'in' + && in_array(AttendeeStatus::AWAITING_PAYMENT->name, $statusClause[2], true); + })) + ->andReturn(new Collection([$attendee])); + + $this->attendeeRepository->shouldReceive('updateWhere')->once(); + $this->productQuantityService->shouldReceive('decreaseQuantitySold')->once(); + $this->domainEventDispatcherService->shouldReceive('dispatch')->once(); + + $this->service->cancelForOccurrence(1, 10); + + Event::assertDispatched(CapacityChangedEvent::class); + } + + public function test_decrements_attendee_statistics_grouped_by_source_order(): void + { + // Regression: bulk occurrence cancel previously cancelled attendees + + // adjusted inventory but skipped the per-attendee statistics decrement + // PartialEditAttendeeHandler runs. The later refund flow's order-level + // decrement looked at currently-active attendees (zero) and decremented + // attendees_registered by zero — leaving stats inflated. We now group + // by source order and call the stats service once per order so daily + // statistics for the right order date are touched. + $eventId = 5; + $occurrenceId = 50; + + // Two attendees on order 1000 (different products), one on order 1001. + $a1 = $this->makeAttendee(id: 301, productId: 1, productPriceId: 11, orderId: 1000); + $a2 = $this->makeAttendee(id: 302, productId: 2, productPriceId: 22, orderId: 1000); + $a3 = $this->makeAttendee(id: 303, productId: 1, productPriceId: 11, orderId: 1001); + + $this->attendeeRepository + ->shouldReceive('findWhere') + ->andReturn(new Collection([$a1, $a2, $a3])); + $this->attendeeRepository->shouldReceive('updateWhere')->once(); + $this->productQuantityService->shouldReceive('decreaseQuantitySold')->zeroOrMoreTimes(); + $this->domainEventDispatcherService->shouldReceive('dispatch')->zeroOrMoreTimes(); + + $order1000 = $this->makeOrder(id: 1000, createdAt: '2026-01-15 09:00:00'); + $order1001 = $this->makeOrder(id: 1001, createdAt: '2026-01-20 14:30:00'); + + // Order ids are unique-collected before the lookup so order doesn't + // matter — assert set membership. + $this->orderRepository + ->shouldReceive('findWhereIn') + ->once() + ->with('id', Mockery::on(function ($ids) { + if (! is_array($ids)) { + return false; + } + $sorted = $ids; + sort($sorted); + + return $sorted === [1000, 1001]; + })) + ->andReturn(new Collection([$order1000, $order1001])); + + // Mockery's `with()` matches positionally — named-arg calls in the + // service translate to positional under the hood, so assert that way. + $this->statisticsCancellationService + ->shouldReceive('decrementForCancelledAttendee') + ->once() + ->with($eventId, '2026-01-15 09:00:00', 2, $occurrenceId); + $this->statisticsCancellationService + ->shouldReceive('decrementForCancelledAttendee') + ->once() + ->with($eventId, '2026-01-20 14:30:00', 1, $occurrenceId); + + $this->service->cancelForOccurrence($eventId, $occurrenceId); + + // Mockery::close() in tearDown verifies the call expectations above. + // Add a matching PHPUnit assertion so the test isn't marked risky. + $this->assertTrue(true); + } + + public function test_logs_and_continues_when_statistics_decrement_throws(): void + { + // Stats reconciliation failures must not roll back the attendee cancel + // — attendees are already updated, inventory is already adjusted, and + // a stats discrepancy is recoverable. The service swallows the error + // and logs so on-call can spot drift. + $attendee = $this->makeAttendee(id: 401, productId: 1, productPriceId: 11, orderId: 5000); + $this->attendeeRepository + ->shouldReceive('findWhere') + ->andReturn(new Collection([$attendee])); + $this->attendeeRepository->shouldReceive('updateWhere')->once(); + $this->productQuantityService->shouldReceive('decreaseQuantitySold')->once(); + $this->domainEventDispatcherService->shouldReceive('dispatch')->once(); + + $order = $this->makeOrder(id: 5000); + $this->orderRepository + ->shouldReceive('findWhereIn') + ->andReturn(new Collection([$order])); + + $this->statisticsCancellationService + ->shouldReceive('decrementForCancelledAttendee') + ->andThrow(new \RuntimeException('version mismatch')); + + $this->logger + ->shouldReceive('error') + ->once() + ->with( + 'Failed to decrement attendee statistics during occurrence cancellation', + Mockery::on(fn (array $ctx) => ($ctx['order_id'] ?? null) === 5000), + ); + + $this->service->cancelForOccurrence(1, 10); + + // Mockery verifies the logger + decrement expectations on close; + // pair with a PHPUnit assertion so the test isn't marked risky. + $this->assertTrue(true); + } + + private function makeAttendee(int $id, int $productId, int $productPriceId, int $orderId = 1000): MockInterface + { + $attendee = Mockery::mock(AttendeeDomainObject::class); + $attendee->shouldReceive('getId')->andReturn($id); + $attendee->shouldReceive('getProductId')->andReturn($productId); + $attendee->shouldReceive('getProductPriceId')->andReturn($productPriceId); + $attendee->shouldReceive('getOrderId')->andReturn($orderId); + + return $attendee; + } + + private function makeOrder(int $id, string $createdAt = '2026-01-01 12:00:00'): MockInterface + { + $order = Mockery::mock(OrderDomainObject::class); + $order->shouldReceive('getId')->andReturn($id); + $order->shouldReceive('getCreatedAt')->andReturn($createdAt); + + return $order; + } +} diff --git a/backend/tests/Unit/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityServiceTest.php b/backend/tests/Unit/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityServiceTest.php new file mode 100644 index 0000000000..be5778d7c8 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityServiceTest.php @@ -0,0 +1,267 @@ +occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->orderItemRepository = Mockery::mock(OrderItemRepositoryInterface::class); + $this->visibilityRepository = Mockery::mock(ProductOccurrenceVisibilityRepositoryInterface::class); + + $this->orderItemRepository + ->shouldReceive('getReservedQuantityForOccurrence') + ->byDefault() + ->andReturn(0); + + $this->service = new OccurrencePurchaseEligibilityService( + $this->occurrenceRepository, + $this->orderItemRepository, + $this->visibilityRepository, + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function test_rejects_when_occurrence_not_found(): void + { + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->andReturn(null); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('not found'); + + $this->service->assertOccurrencePurchasable(eventId: 1, occurrenceId: 99); + } + + public function test_rejects_cancelled_occurrence(): void + { + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->andReturn($this->occurrence(EventOccurrenceStatus::CANCELLED->name)); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('cancelled'); + + $this->service->assertOccurrencePurchasable(eventId: 1, occurrenceId: 10); + } + + public function test_rejects_past_occurrence(): void + { + // Public payload filters past occurrences out, but a stale client or a + // direct API caller could still post one — guard belongs at the + // eligibility chokepoint so public checkout, manual attendee creation + // and any future caller all inherit it. + $occurrence = $this->occurrence( + status: EventOccurrenceStatus::ACTIVE->name, + startDate: '2020-01-01 10:00:00', + ); + $this->occurrenceRepository->shouldReceive('findFirstWhere')->andReturn($occurrence); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('already ended'); + + $this->service->assertOccurrencePurchasable(eventId: 1, occurrenceId: 10); + } + + public function test_rejects_past_occurrence_even_with_capacity_override(): void + { + // Organisers using the override flag to manually add an attendee should + // still not be able to issue tickets for a session that has ended — + // override only bypasses capacity, not time/status gates. + $occurrence = $this->occurrence( + status: EventOccurrenceStatus::ACTIVE->name, + startDate: '2020-01-01 10:00:00', + ); + $this->occurrenceRepository->shouldReceive('findFirstWhere')->andReturn($occurrence); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('already ended'); + + $this->service->assertOccurrencePurchasable( + eventId: 1, + occurrenceId: 10, + overrideCapacity: true, + ); + } + + public function test_rejects_sold_out_occurrence(): void + { + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->andReturn($this->occurrence(EventOccurrenceStatus::SOLD_OUT->name)); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('sold out'); + + $this->service->assertOccurrencePurchasable(eventId: 1, occurrenceId: 10); + } + + public function test_rejects_when_capacity_exceeded(): void + { + // capacity 10, used 4, reserved 3 → available 3; request 5 → reject. + $occurrence = $this->occurrence(EventOccurrenceStatus::ACTIVE->name, capacity: 10, usedCapacity: 4); + $this->occurrenceRepository->shouldReceive('findFirstWhere')->andReturn($occurrence); + $this->orderItemRepository + ->shouldReceive('getReservedQuantityForOccurrence') + ->with(10) + ->andReturn(3); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('capacity'); + + $this->service->assertOccurrencePurchasable(eventId: 1, occurrenceId: 10, additionalQuantity: 5); + } + + public function test_allows_purchase_within_capacity(): void + { + $occurrence = $this->occurrence(EventOccurrenceStatus::ACTIVE->name, capacity: 10, usedCapacity: 4); + $this->occurrenceRepository->shouldReceive('findFirstWhere')->andReturn($occurrence); + + $result = $this->service->assertOccurrencePurchasable( + eventId: 1, + occurrenceId: 10, + additionalQuantity: 3, + ); + + $this->assertSame($occurrence, $result); + } + + public function test_override_capacity_bypasses_capacity_check_but_not_cancelled(): void + { + // Override means capacity is ignored, but cancelled still blocks — there's + // no point overriding into a cancelled occurrence. + $occurrence = $this->occurrence(EventOccurrenceStatus::CANCELLED->name, capacity: 1, usedCapacity: 0); + $this->occurrenceRepository->shouldReceive('findFirstWhere')->andReturn($occurrence); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('cancelled'); + + $this->service->assertOccurrencePurchasable( + eventId: 1, + occurrenceId: 10, + additionalQuantity: 999, + overrideCapacity: true, + ); + } + + public function test_override_capacity_bypasses_sold_out_status(): void + { + // SOLD_OUT is a capacity-derived status — ProductQuantityUpdateService + // flips it once used_capacity hits capacity. The override flag is + // specifically for the "full occurrence, organiser still wants to add + // someone" case, so it has to bypass SOLD_OUT too. + $occurrence = $this->occurrence(EventOccurrenceStatus::SOLD_OUT->name, capacity: 10, usedCapacity: 10); + $this->occurrenceRepository->shouldReceive('findFirstWhere')->andReturn($occurrence); + $this->orderItemRepository->shouldNotReceive('getReservedQuantityForOccurrence'); + + $result = $this->service->assertOccurrencePurchasable( + eventId: 1, + occurrenceId: 10, + additionalQuantity: 1, + overrideCapacity: true, + ); + + $this->assertSame($occurrence, $result); + } + + public function test_override_capacity_allows_exceeding_capacity_for_active_occurrence(): void + { + // capacity 1, request 50, with override: should pass. + $occurrence = $this->occurrence(EventOccurrenceStatus::ACTIVE->name, capacity: 1, usedCapacity: 5); + $this->occurrenceRepository->shouldReceive('findFirstWhere')->andReturn($occurrence); + + // Capacity check is short-circuited so reserved-quantity lookup must + // never run — keeps the override path cheap. + $this->orderItemRepository->shouldNotReceive('getReservedQuantityForOccurrence'); + + $result = $this->service->assertOccurrencePurchasable( + eventId: 1, + occurrenceId: 10, + additionalQuantity: 50, + overrideCapacity: true, + ); + + $this->assertSame($occurrence, $result); + } + + public function test_product_visibility_allows_all_when_no_rules_exist(): void + { + $this->visibilityRepository + ->shouldReceive('findWhereIn') + ->andReturn(collect()); + + // No exception means all products are allowed (default-visible). + $this->service->assertProductsVisibleOnOccurrence(occurrenceId: 10, productIds: [1, 2, 3]); + $this->assertTrue(true); + } + + public function test_product_visibility_rejects_hidden_product(): void + { + $rule = (new ProductOccurrenceVisibilityDomainObject) + ->setEventOccurrenceId(10) + ->setProductId(1); + + $this->visibilityRepository + ->shouldReceive('findWhereIn') + ->andReturn(collect([$rule])); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('not available for this occurrence'); + + // Product 99 is not in the allow-list. + $this->service->assertProductsVisibleOnOccurrence(occurrenceId: 10, productIds: [1, 99]); + } + + public function test_product_visibility_no_op_for_empty_product_list(): void + { + // Edge case: empty product list shouldn't even hit the repository. + $this->visibilityRepository->shouldNotReceive('findWhereIn'); + + $this->service->assertProductsVisibleOnOccurrence(occurrenceId: 10, productIds: []); + $this->assertTrue(true); + } + + private function occurrence( + string $status, + ?int $capacity = null, + int $usedCapacity = 0, + string $startDate = '2099-06-15 10:00:00', + ): EventOccurrenceDomainObject { + return (new EventOccurrenceDomainObject) + ->setId(10) + ->setEventId(1) + ->setStatus($status) + ->setCapacity($capacity) + ->setUsedCapacity($usedCapacity) + ->setStartDate($startDate); + } +} diff --git a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsCancellationServiceTest.php b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsCancellationServiceTest.php index 042b44af75..838e077d6e 100644 --- a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsCancellationServiceTest.php +++ b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsCancellationServiceTest.php @@ -10,6 +10,8 @@ use HiEvents\DomainObjects\Status\AttendeeStatus; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceDailyStatisticRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Services\Domain\EventStatistics\EventStatisticsCancellationService; @@ -38,6 +40,10 @@ protected function setUp(): void $this->eventStatisticsRepository = Mockery::mock(EventStatisticRepositoryInterface::class); $this->eventDailyStatisticRepository = Mockery::mock(EventDailyStatisticRepositoryInterface::class); + $eventOccurrenceStatisticRepository = Mockery::mock(EventOccurrenceStatisticRepositoryInterface::class); + $eventOccurrenceStatisticRepository->shouldReceive('findFirstWhere')->andReturnNull(); + $eventOccurrenceDailyStatisticRepository = Mockery::mock(EventOccurrenceDailyStatisticRepositoryInterface::class); + $eventOccurrenceDailyStatisticRepository->shouldReceive('findFirstWhere')->andReturnNull(); $this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class); $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class); $this->databaseManager = Mockery::mock(DatabaseManager::class); @@ -47,6 +53,8 @@ protected function setUp(): void $this->service = new EventStatisticsCancellationService( $this->eventStatisticsRepository, $this->eventDailyStatisticRepository, + $eventOccurrenceStatisticRepository, + $eventOccurrenceDailyStatisticRepository, $this->attendeeRepository, $this->orderRepository, $this->logger, @@ -64,9 +72,11 @@ public function testDecrementForCancelledOrderSuccess(): void // Create mock order items $ticketOrderItem1 = Mockery::mock(OrderItemDomainObject::class); $ticketOrderItem1->shouldReceive('getQuantity')->andReturn(2); + $ticketOrderItem1->shouldReceive('getEventOccurrenceId')->andReturnNull(); $ticketOrderItem2 = Mockery::mock(OrderItemDomainObject::class); $ticketOrderItem2->shouldReceive('getQuantity')->andReturn(1); + $ticketOrderItem2->shouldReceive('getEventOccurrenceId')->andReturnNull(); $orderItems = new Collection([$ticketOrderItem1, $ticketOrderItem2]); $ticketOrderItems = new Collection([$ticketOrderItem1, $ticketOrderItem2]); diff --git a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsIncrementServiceTest.php b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsIncrementServiceTest.php index 107a1257e9..2255eb5fe1 100644 --- a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsIncrementServiceTest.php +++ b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsIncrementServiceTest.php @@ -9,6 +9,8 @@ use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceDailyStatisticRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; @@ -42,6 +44,8 @@ protected function setUp(): void $this->productRepository = Mockery::mock(ProductRepositoryInterface::class); $this->eventStatisticsRepository = Mockery::mock(EventStatisticRepositoryInterface::class); $this->eventDailyStatisticRepository = Mockery::mock(EventDailyStatisticRepositoryInterface::class); + $eventOccurrenceStatisticRepository = Mockery::mock(EventOccurrenceStatisticRepositoryInterface::class); + $eventOccurrenceDailyStatisticRepository = Mockery::mock(EventOccurrenceDailyStatisticRepositoryInterface::class); $this->databaseManager = Mockery::mock(DatabaseManager::class); $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class); $this->logger = Mockery::mock(LoggerInterface::class); @@ -52,6 +56,8 @@ protected function setUp(): void $this->productRepository, $this->eventStatisticsRepository, $this->eventDailyStatisticRepository, + $eventOccurrenceStatisticRepository, + $eventOccurrenceDailyStatisticRepository, $this->databaseManager, $this->orderRepository, $this->logger, @@ -71,11 +77,13 @@ public function testIncrementForOrderWithExistingStatistics(): void $ticketOrderItem1->shouldReceive('getQuantity')->andReturn(2); $ticketOrderItem1->shouldReceive('getProductId')->andReturn(1); $ticketOrderItem1->shouldReceive('getTotalBeforeAdditions')->andReturn(100.00); + $ticketOrderItem1->shouldReceive('getEventOccurrenceId')->andReturnNull(); $ticketOrderItem2 = Mockery::mock(OrderItemDomainObject::class); $ticketOrderItem2->shouldReceive('getQuantity')->andReturn(1); $ticketOrderItem2->shouldReceive('getProductId')->andReturn(2); $ticketOrderItem2->shouldReceive('getTotalBeforeAdditions')->andReturn(50.00); + $ticketOrderItem2->shouldReceive('getEventOccurrenceId')->andReturnNull(); $orderItems = new Collection([$ticketOrderItem1, $ticketOrderItem2]); $ticketOrderItems = new Collection([$ticketOrderItem1, $ticketOrderItem2]); @@ -241,6 +249,7 @@ public function testIncrementForOrderCreatesNewStatistics(): void $orderItem->shouldReceive('getQuantity')->andReturn(2); $orderItem->shouldReceive('getProductId')->andReturn(1); $orderItem->shouldReceive('getTotalBeforeAdditions')->andReturn(100.00); + $orderItem->shouldReceive('getEventOccurrenceId')->andReturnNull(); $orderItems = new Collection([$orderItem]); $ticketOrderItems = new Collection([$orderItem]); diff --git a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsRefundServiceTest.php b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsRefundServiceTest.php index 5e41a74713..361d9c6400 100644 --- a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsRefundServiceTest.php +++ b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsRefundServiceTest.php @@ -5,10 +5,16 @@ use HiEvents\DomainObjects\EventDailyStatisticDomainObject; use HiEvents\DomainObjects\EventStatisticDomainObject; use HiEvents\DomainObjects\OrderDomainObject; +use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceDailyStatisticRepositoryInterface; +use HiEvents\Repository\Interfaces\EventOccurrenceStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface; +use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Services\Domain\EventStatistics\EventStatisticsRefundService; use HiEvents\Values\MoneyValue; +use Illuminate\Database\Query\Expression; +use Illuminate\Support\Collection; use Mockery; use Mockery\MockInterface; use Psr\Log\LoggerInterface; @@ -20,6 +26,9 @@ class EventStatisticsRefundServiceTest extends TestCase private EventStatisticsRefundService $service; private MockInterface|EventStatisticRepositoryInterface $eventStatisticsRepository; private MockInterface|EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository; + private MockInterface|EventOccurrenceStatisticRepositoryInterface $eventOccurrenceStatisticRepository; + private MockInterface|EventOccurrenceDailyStatisticRepositoryInterface $eventOccurrenceDailyStatisticRepository; + private MockInterface|OrderRepositoryInterface $orderRepository; private MockInterface|LoggerInterface $logger; protected function setUp(): void @@ -28,15 +37,49 @@ protected function setUp(): void $this->eventStatisticsRepository = Mockery::mock(EventStatisticRepositoryInterface::class); $this->eventDailyStatisticRepository = Mockery::mock(EventDailyStatisticRepositoryInterface::class); + $this->eventOccurrenceStatisticRepository = Mockery::mock(EventOccurrenceStatisticRepositoryInterface::class); + $this->eventOccurrenceDailyStatisticRepository = Mockery::mock(EventOccurrenceDailyStatisticRepositoryInterface::class); + $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class); $this->logger = Mockery::mock(LoggerInterface::class); + // Default: the order reload (eager-loading items for the occurrence path) returns + // an order with no occurrence items so the occurrence pass is skipped. Tests that + // exercise the occurrence path override this expectation. + $this->stubOrderReload(totalGross: 0.0, items: []); + $this->service = new EventStatisticsRefundService( $this->eventStatisticsRepository, $this->eventDailyStatisticRepository, + $this->eventOccurrenceStatisticRepository, + $this->eventOccurrenceDailyStatisticRepository, + $this->orderRepository, $this->logger ); } + /** + * Helper that stubs `orderRepository->loadRelation(...)->findById(...)` to return + * an OrderDomainObject pre-stocked with the given items + totalGross + createdAt. + * + * @param OrderItemDomainObject[] $items + */ + private function stubOrderReload( + float $totalGross, + array $items, + string $createdAt = '2026-04-10 09:00:00', + ): MockInterface { + $reloaded = Mockery::mock(OrderDomainObject::class); + $reloaded->shouldReceive('getOrderItems')->andReturn(new Collection($items)); + $reloaded->shouldReceive('getTotalGross')->andReturn($totalGross); + $reloaded->shouldReceive('getCreatedAt')->andReturn($createdAt); + + $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->orderRepository->shouldReceive('findById')->andReturn($reloaded); + + return $reloaded; + } + + public function testUpdateForRefundFullAmount(): void { $eventId = 1; @@ -44,7 +87,6 @@ public function testUpdateForRefundFullAmount(): void $orderDate = '2024-01-15 10:30:00'; $currency = 'USD'; - // Create mock order $order = Mockery::mock(OrderDomainObject::class); $order->shouldReceive('getEventId')->andReturn($eventId); $order->shouldReceive('getId')->andReturn($orderId); @@ -54,44 +96,38 @@ public function testUpdateForRefundFullAmount(): void $order->shouldReceive('getTotalTax')->andReturn(8.00); $order->shouldReceive('getTotalFee')->andReturn(2.00); - // Create refund amount (full refund) $refundAmount = MoneyValue::fromFloat(100.00, $currency); - // Mock aggregate event statistics $eventStatistics = Mockery::mock(EventStatisticDomainObject::class); $eventStatistics->shouldReceive('getSalesTotalGross')->andReturn(1000.00); $eventStatistics->shouldReceive('getTotalRefunded')->andReturn(50.00); $eventStatistics->shouldReceive('getTotalTax')->andReturn(80.00); $eventStatistics->shouldReceive('getTotalFee')->andReturn(20.00); - // Mock daily event statistics $eventDailyStatistic = Mockery::mock(EventDailyStatisticDomainObject::class); $eventDailyStatistic->shouldReceive('getSalesTotalGross')->andReturn(500.00); $eventDailyStatistic->shouldReceive('getTotalRefunded')->andReturn(25.00); $eventDailyStatistic->shouldReceive('getTotalTax')->andReturn(40.00); $eventDailyStatistic->shouldReceive('getTotalFee')->andReturn(10.00); - // Expect finding aggregate statistics $this->eventStatisticsRepository ->shouldReceive('findFirstWhere') ->with(['event_id' => $eventId]) ->andReturn($eventStatistics); - // Expect updating aggregate statistics (full refund = 100% proportion) $this->eventStatisticsRepository ->shouldReceive('updateWhere') ->with( [ - 'sales_total_gross' => 900.00, // 1000 - 100 - 'total_refunded' => 150.00, // 50 + 100 - 'total_tax' => 72.00, // 80 - 8 (100% of order tax) - 'total_fee' => 18.00, // 20 - 2 (100% of order fee) + 'sales_total_gross' => 900.00, + 'total_refunded' => 150.00, + 'total_tax' => 72.00, + 'total_fee' => 18.00, ], ['event_id' => $eventId] ) ->once(); - // Expect finding daily statistics $this->eventDailyStatisticRepository ->shouldReceive('findFirstWhere') ->with([ @@ -100,15 +136,14 @@ public function testUpdateForRefundFullAmount(): void ]) ->andReturn($eventDailyStatistic); - // Expect updating daily statistics $this->eventDailyStatisticRepository ->shouldReceive('updateWhere') ->with( [ - 'sales_total_gross' => 400.00, // 500 - 100 - 'total_refunded' => 125.00, // 25 + 100 - 'total_tax' => 32.00, // 40 - 8 - 'total_fee' => 8.00, // 10 - 2 + 'sales_total_gross' => 400.00, + 'total_refunded' => 125.00, + 'total_tax' => 32.00, + 'total_fee' => 8.00, ], [ 'event_id' => $eventId, @@ -117,13 +152,16 @@ public function testUpdateForRefundFullAmount(): void ) ->once(); - // Expect logging + // Default setUp stubs the order reload to return totalGross=0 with no items, so + // the occurrence pass must be skipped entirely. Assert that — nothing here exercises + // the new B4 / B5 code paths. + $this->eventOccurrenceStatisticRepository->shouldNotReceive('updateWhere'); + $this->eventOccurrenceDailyStatisticRepository->shouldNotReceive('updateWhere'); + $this->logger->shouldReceive('info')->twice(); - // Execute $this->service->updateForRefund($order, $refundAmount); - $this->assertTrue(true); } @@ -134,7 +172,6 @@ public function testUpdateForRefundPartialAmount(): void $orderDate = '2024-01-15 10:30:00'; $currency = 'USD'; - // Create mock order $order = Mockery::mock(OrderDomainObject::class); $order->shouldReceive('getEventId')->andReturn($eventId); $order->shouldReceive('getId')->andReturn($orderId); @@ -144,44 +181,38 @@ public function testUpdateForRefundPartialAmount(): void $order->shouldReceive('getTotalTax')->andReturn(8.00); $order->shouldReceive('getTotalFee')->andReturn(2.00); - // Create refund amount (50% partial refund) $refundAmount = MoneyValue::fromFloat(50.00, $currency); - // Mock aggregate event statistics $eventStatistics = Mockery::mock(EventStatisticDomainObject::class); $eventStatistics->shouldReceive('getSalesTotalGross')->andReturn(1000.00); $eventStatistics->shouldReceive('getTotalRefunded')->andReturn(50.00); $eventStatistics->shouldReceive('getTotalTax')->andReturn(80.00); $eventStatistics->shouldReceive('getTotalFee')->andReturn(20.00); - // Mock daily event statistics $eventDailyStatistic = Mockery::mock(EventDailyStatisticDomainObject::class); $eventDailyStatistic->shouldReceive('getSalesTotalGross')->andReturn(500.00); $eventDailyStatistic->shouldReceive('getTotalRefunded')->andReturn(25.00); $eventDailyStatistic->shouldReceive('getTotalTax')->andReturn(40.00); $eventDailyStatistic->shouldReceive('getTotalFee')->andReturn(10.00); - // Expect finding aggregate statistics $this->eventStatisticsRepository ->shouldReceive('findFirstWhere') ->with(['event_id' => $eventId]) ->andReturn($eventStatistics); - // Expect updating aggregate statistics (50% refund = 0.5 proportion) $this->eventStatisticsRepository ->shouldReceive('updateWhere') ->with( [ - 'sales_total_gross' => 950.00, // 1000 - 50 - 'total_refunded' => 100.00, // 50 + 50 - 'total_tax' => 76.00, // 80 - 4 (50% of order tax) - 'total_fee' => 19.00, // 20 - 1 (50% of order fee) + 'sales_total_gross' => 950.00, + 'total_refunded' => 100.00, + 'total_tax' => 76.00, + 'total_fee' => 19.00, ], ['event_id' => $eventId] ) ->once(); - // Expect finding daily statistics $this->eventDailyStatisticRepository ->shouldReceive('findFirstWhere') ->with([ @@ -190,15 +221,14 @@ public function testUpdateForRefundPartialAmount(): void ]) ->andReturn($eventDailyStatistic); - // Expect updating daily statistics $this->eventDailyStatisticRepository ->shouldReceive('updateWhere') ->with( [ - 'sales_total_gross' => 450.00, // 500 - 50 - 'total_refunded' => 75.00, // 25 + 50 - 'total_tax' => 36.00, // 40 - 4 - 'total_fee' => 9.00, // 10 - 1 + 'sales_total_gross' => 450.00, + 'total_refunded' => 75.00, + 'total_tax' => 36.00, + 'total_fee' => 9.00, ], [ 'event_id' => $eventId, @@ -207,13 +237,13 @@ public function testUpdateForRefundPartialAmount(): void ) ->once(); - // Expect logging + $this->eventOccurrenceStatisticRepository->shouldNotReceive('updateWhere'); + $this->eventOccurrenceDailyStatisticRepository->shouldNotReceive('updateWhere'); + $this->logger->shouldReceive('info')->twice(); - // Execute $this->service->updateForRefund($order, $refundAmount); - $this->assertTrue(true); } @@ -223,30 +253,22 @@ public function testThrowsExceptionWhenAggregateStatisticsNotFound(): void $orderId = 123; $currency = 'USD'; - // Create mock order $order = Mockery::mock(OrderDomainObject::class); $order->shouldReceive('getEventId')->andReturn($eventId); $order->shouldReceive('getId')->andReturn($orderId); $order->shouldReceive('getCurrency')->andReturn($currency); - // Create refund amount $refundAmount = MoneyValue::fromFloat(50.00, $currency); - // Expect aggregate statistics not found $this->eventStatisticsRepository ->shouldReceive('findFirstWhere') ->with(['event_id' => $eventId]) ->andReturnNull(); - // Expect exception $this->expectException(ResourceNotFoundException::class); $this->expectExceptionMessage("Event statistics not found for event {$eventId}"); - // Execute $this->service->updateForRefund($order, $refundAmount); - - - $this->assertTrue(true); } public function testLogsWarningWhenDailyStatisticsNotFound(): void @@ -256,7 +278,6 @@ public function testLogsWarningWhenDailyStatisticsNotFound(): void $orderDate = '2024-01-15 10:30:00'; $currency = 'USD'; - // Create mock order $order = Mockery::mock(OrderDomainObject::class); $order->shouldReceive('getEventId')->andReturn($eventId); $order->shouldReceive('getId')->andReturn($orderId); @@ -266,28 +287,23 @@ public function testLogsWarningWhenDailyStatisticsNotFound(): void $order->shouldReceive('getTotalTax')->andReturn(8.00); $order->shouldReceive('getTotalFee')->andReturn(2.00); - // Create refund amount $refundAmount = MoneyValue::fromFloat(50.00, $currency); - // Mock aggregate event statistics $eventStatistics = Mockery::mock(EventStatisticDomainObject::class); $eventStatistics->shouldReceive('getSalesTotalGross')->andReturn(1000.00); $eventStatistics->shouldReceive('getTotalRefunded')->andReturn(50.00); $eventStatistics->shouldReceive('getTotalTax')->andReturn(80.00); $eventStatistics->shouldReceive('getTotalFee')->andReturn(20.00); - // Expect finding aggregate statistics $this->eventStatisticsRepository ->shouldReceive('findFirstWhere') ->with(['event_id' => $eventId]) ->andReturn($eventStatistics); - // Expect updating aggregate statistics $this->eventStatisticsRepository ->shouldReceive('updateWhere') ->once(); - // Expect daily statistics not found $this->eventDailyStatisticRepository ->shouldReceive('findFirstWhere') ->with([ @@ -296,7 +312,6 @@ public function testLogsWarningWhenDailyStatisticsNotFound(): void ]) ->andReturnNull(); - // Expect warning log for missing daily statistics $this->logger ->shouldReceive('warning') ->with( @@ -309,19 +324,284 @@ public function testLogsWarningWhenDailyStatisticsNotFound(): void ) ->once(); - // Expect info log for aggregate update $this->logger->shouldReceive('info')->once(); - // Should not attempt to update daily statistics $this->eventDailyStatisticRepository->shouldNotReceive('updateWhere'); - // Execute $this->service->updateForRefund($order, $refundAmount); + $this->assertTrue(true); + } + + /** + * The order is loaded with order_items so the occurrence pass can run. Verify that: + * 1. The order reload happens exactly ONCE (perf fix — used to load twice). + * 2. updateWhere fires on both occurrence stats and occurrence daily stats. + * 3. The deltas are emitted as DB::raw atomic increments (not scalars). + * 4. The version column is bumped via raw SQL so optimistic readers see the change. + */ + public function testUpdateForRefundUpdatesOccurrenceStatsForOrderWithItems(): void + { + $eventId = 1; + $orderId = 123; + $orderDate = '2024-01-15 10:30:00'; + $currency = 'USD'; + + $order = $this->makeBaseOrderMock($eventId, $orderId, $orderDate, totalGross: 100.00); + $refundAmount = MoneyValue::fromFloat(100.00, $currency); + + // Order reload returns one item on occurrence 50. + $item = $this->makeOrderItemMock( + occurrenceId: 50, + totalGross: 100.00, + totalTax: 8.00, + totalServiceFee: 2.00, + ); + + // Override the default no-items reload — and assert it happens exactly once + // across the whole flow (regression guard for the perf duplication fix). + $reloaded = Mockery::mock(OrderDomainObject::class); + $reloaded->shouldReceive('getOrderItems')->andReturn(new Collection([$item])); + $reloaded->shouldReceive('getTotalGross')->andReturn(100.00); + $reloaded->shouldReceive('getCreatedAt')->andReturn($orderDate); + + $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class); + $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->orderRepository->shouldReceive('findById')->with($orderId)->once()->andReturn($reloaded); + + $this->service = new EventStatisticsRefundService( + $this->eventStatisticsRepository, + $this->eventDailyStatisticRepository, + $this->eventOccurrenceStatisticRepository, + $this->eventOccurrenceDailyStatisticRepository, + $this->orderRepository, + $this->logger + ); + + $this->stubAggregateAndDailyPaths($eventId); + + $this->eventOccurrenceStatisticRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + Mockery::on(fn(array $attrs) => + $this->isRawIncrement($attrs['sales_total_gross'] ?? null, 'sales_total_gross', '-') + && $this->isRawIncrement($attrs['total_refunded'] ?? null, 'total_refunded', '+') + && $this->isRawIncrement($attrs['total_tax'] ?? null, 'total_tax', '-') + && $this->isRawIncrement($attrs['total_fee'] ?? null, 'total_fee', '-') + && $this->isVersionBump($attrs['version'] ?? null) + ), + ['event_occurrence_id' => 50] + ); + + $this->eventOccurrenceDailyStatisticRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + Mockery::on(fn(array $attrs) => + $this->isRawIncrement($attrs['sales_total_gross'] ?? null, 'sales_total_gross', '-') + && $this->isRawIncrement($attrs['total_refunded'] ?? null, 'total_refunded', '+') + && $this->isVersionBump($attrs['version'] ?? null) + ), + ['event_occurrence_id' => 50, 'date' => '2024-01-15'] + ); + + $this->logger->shouldReceive('info')->twice(); + + $this->service->updateForRefund($order, $refundAmount); $this->assertTrue(true); } + /** + * An order with items split across two different occurrences must produce one + * updateWhere call per occurrence on each stats repository (4 calls total). + */ + public function testUpdateForRefundSplitsRefundAcrossMultipleOccurrences(): void + { + $eventId = 1; + $orderId = 200; + $orderDate = '2024-02-20 14:00:00'; + $currency = 'USD'; + + $order = $this->makeBaseOrderMock($eventId, $orderId, $orderDate, totalGross: 200.00); + $refundAmount = MoneyValue::fromFloat(200.00, $currency); + + // 60% of the order belongs to occurrence 100, 40% to occurrence 200. + $itemA = $this->makeOrderItemMock(occurrenceId: 100, totalGross: 120.00, totalTax: 10.00, totalServiceFee: 2.00); + $itemB = $this->makeOrderItemMock(occurrenceId: 200, totalGross: 80.00, totalTax: 6.00, totalServiceFee: 2.00); + + $reloaded = Mockery::mock(OrderDomainObject::class); + $reloaded->shouldReceive('getOrderItems')->andReturn(new Collection([$itemA, $itemB])); + $reloaded->shouldReceive('getTotalGross')->andReturn(200.00); + $reloaded->shouldReceive('getCreatedAt')->andReturn($orderDate); + + $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class); + $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->orderRepository->shouldReceive('findById')->with($orderId)->once()->andReturn($reloaded); + + $this->service = new EventStatisticsRefundService( + $this->eventStatisticsRepository, + $this->eventDailyStatisticRepository, + $this->eventOccurrenceStatisticRepository, + $this->eventOccurrenceDailyStatisticRepository, + $this->orderRepository, + $this->logger + ); + + $this->stubAggregateAndDailyPaths($eventId); + + // Expect one updateWhere per occurrence on each occurrence-stats repo. Order + // shouldn't matter (PHP foreach over the items map preserves insertion order). + $this->eventOccurrenceStatisticRepository + ->shouldReceive('updateWhere') + ->once() + ->with(Mockery::any(), ['event_occurrence_id' => 100]); + + $this->eventOccurrenceStatisticRepository + ->shouldReceive('updateWhere') + ->once() + ->with(Mockery::any(), ['event_occurrence_id' => 200]); + + $this->eventOccurrenceDailyStatisticRepository + ->shouldReceive('updateWhere') + ->once() + ->with(Mockery::any(), ['event_occurrence_id' => 100, 'date' => '2024-02-20']); + + $this->eventOccurrenceDailyStatisticRepository + ->shouldReceive('updateWhere') + ->once() + ->with(Mockery::any(), ['event_occurrence_id' => 200, 'date' => '2024-02-20']); + + $this->logger->shouldReceive('info')->twice(); + + $this->service->updateForRefund($order, $refundAmount); + + $this->assertTrue(true); + } + + /** + * Order items without an event_occurrence_id (legacy / non-recurring orders) must + * not trigger any occurrence-stats updates. + */ + public function testUpdateForRefundSkipsOccurrencePathWhenNoItemsHaveOccurrenceId(): void + { + $eventId = 1; + $orderId = 300; + $orderDate = '2024-03-10 12:00:00'; + $currency = 'USD'; + + $order = $this->makeBaseOrderMock($eventId, $orderId, $orderDate, totalGross: 50.00); + $refundAmount = MoneyValue::fromFloat(50.00, $currency); + + $itemWithoutOccurrence = $this->makeOrderItemMock( + occurrenceId: null, + totalGross: 50.00, + totalTax: 4.00, + totalServiceFee: 1.00, + ); + + $reloaded = Mockery::mock(OrderDomainObject::class); + $reloaded->shouldReceive('getOrderItems')->andReturn(new Collection([$itemWithoutOccurrence])); + $reloaded->shouldReceive('getTotalGross')->andReturn(50.00); + $reloaded->shouldReceive('getCreatedAt')->andReturn($orderDate); + + $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class); + $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->orderRepository->shouldReceive('findById')->with($orderId)->once()->andReturn($reloaded); + + $this->service = new EventStatisticsRefundService( + $this->eventStatisticsRepository, + $this->eventDailyStatisticRepository, + $this->eventOccurrenceStatisticRepository, + $this->eventOccurrenceDailyStatisticRepository, + $this->orderRepository, + $this->logger + ); + + $this->stubAggregateAndDailyPaths($eventId); + + $this->eventOccurrenceStatisticRepository->shouldNotReceive('updateWhere'); + $this->eventOccurrenceDailyStatisticRepository->shouldNotReceive('updateWhere'); + + $this->logger->shouldReceive('info')->twice(); + + $this->service->updateForRefund($order, $refundAmount); + + $this->assertTrue(true); + } + + /** + * Stubs the aggregate + daily stats lookups + updateWhere calls so the test can + * focus on the occurrence path. Returns nothing — sets up Mockery expectations. + */ + private function stubAggregateAndDailyPaths(int $eventId): void + { + $eventStatistics = Mockery::mock(EventStatisticDomainObject::class); + $eventStatistics->shouldReceive('getSalesTotalGross')->andReturn(1000.00); + $eventStatistics->shouldReceive('getTotalRefunded')->andReturn(0.0); + $eventStatistics->shouldReceive('getTotalTax')->andReturn(80.00); + $eventStatistics->shouldReceive('getTotalFee')->andReturn(20.00); + + $eventDailyStatistic = Mockery::mock(EventDailyStatisticDomainObject::class); + $eventDailyStatistic->shouldReceive('getSalesTotalGross')->andReturn(500.00); + $eventDailyStatistic->shouldReceive('getTotalRefunded')->andReturn(0.0); + $eventDailyStatistic->shouldReceive('getTotalTax')->andReturn(40.00); + $eventDailyStatistic->shouldReceive('getTotalFee')->andReturn(10.00); + + $this->eventStatisticsRepository + ->shouldReceive('findFirstWhere') + ->with(['event_id' => $eventId]) + ->andReturn($eventStatistics); + $this->eventStatisticsRepository->shouldReceive('updateWhere')->once(); + + $this->eventDailyStatisticRepository + ->shouldReceive('findFirstWhere') + ->andReturn($eventDailyStatistic); + $this->eventDailyStatisticRepository->shouldReceive('updateWhere')->once(); + } + + private function makeBaseOrderMock(int $eventId, int $orderId, string $orderDate, float $totalGross): MockInterface + { + $order = Mockery::mock(OrderDomainObject::class); + $order->shouldReceive('getEventId')->andReturn($eventId); + $order->shouldReceive('getId')->andReturn($orderId); + $order->shouldReceive('getCreatedAt')->andReturn($orderDate); + $order->shouldReceive('getCurrency')->andReturn('USD'); + $order->shouldReceive('getTotalGross')->andReturn($totalGross); + $order->shouldReceive('getTotalTax')->andReturn(0.0); + $order->shouldReceive('getTotalFee')->andReturn(0.0); + return $order; + } + + private function makeOrderItemMock(?int $occurrenceId, float $totalGross, float $totalTax, float $totalServiceFee): MockInterface + { + $item = Mockery::mock(OrderItemDomainObject::class); + $item->shouldReceive('getEventOccurrenceId')->andReturn($occurrenceId); + $item->shouldReceive('getTotalGross')->andReturn($totalGross); + $item->shouldReceive('getTotalTax')->andReturn($totalTax); + $item->shouldReceive('getTotalServiceFee')->andReturn($totalServiceFee); + return $item; + } + + private function isRawIncrement(mixed $value, string $column, string $op): bool + { + if (!$value instanceof Expression) { + return false; + } + $sql = (string) $value->getValue(\DB::connection()->getQueryGrammar()); + return str_contains($sql, $column) && str_contains($sql, $op); + } + + private function isVersionBump(mixed $value): bool + { + if (!$value instanceof Expression) { + return false; + } + $sql = (string) $value->getValue(\DB::connection()->getQueryGrammar()); + return $sql === 'version + 1'; + } + protected function tearDown(): void { Mockery::close(); diff --git a/backend/tests/Unit/Services/Domain/Order/OrderApplicationFeeCalculationServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderApplicationFeeCalculationServiceTest.php index 2566caebbb..ea24517227 100644 --- a/backend/tests/Unit/Services/Domain/Order/OrderApplicationFeeCalculationServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Order/OrderApplicationFeeCalculationServiceTest.php @@ -2,7 +2,7 @@ namespace Tests\Unit\Services\Domain\Order; -use HiEvents\DomainObjects\AccountConfigurationDomainObject; +use HiEvents\DomainObjects\OrganizerConfigurationDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\Services\Domain\Order\OrderApplicationFeeCalculationService; @@ -52,9 +52,9 @@ private function createItem(float $price, int $quantity): OrderItemDomainObject return $item; } - private function createAccountConfig(float $fixedFee = 0, float $percentageFee = 0, string $currency = 'USD'): AccountConfigurationDomainObject + private function createAccountConfig(float $fixedFee = 0, float $percentageFee = 0, string $currency = 'USD'): OrganizerConfigurationDomainObject { - $config = $this->getMockBuilder(AccountConfigurationDomainObject::class) + $config = $this->getMockBuilder(OrganizerConfigurationDomainObject::class) ->disableOriginalConstructor() ->onlyMethods(['getFixedApplicationFee', 'getPercentageApplicationFee', 'getApplicationFeeCurrency']) ->getMock(); diff --git a/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php index 61a44df095..f73de994cb 100644 --- a/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php @@ -77,12 +77,14 @@ public function testCancelOrder(): void $order->shouldReceive('getLocale')->andReturn('en'); $attendee1 = m::mock(AttendeeDomainObject::class); - $attendee1->shouldReceive('getproductPriceId')->andReturn(1); + $attendee1->shouldReceive('getProductPriceId')->andReturn(1); $attendee1->shouldReceive('getProductId')->andReturn(10); + $attendee1->shouldReceive('getEventOccurrenceId')->andReturn(1); $attendee2 = m::mock(AttendeeDomainObject::class); - $attendee2->shouldReceive('getproductPriceId')->andReturn(2); + $attendee2->shouldReceive('getProductPriceId')->andReturn(2); $attendee2->shouldReceive('getProductId')->andReturn(20); + $attendee2->shouldReceive('getEventOccurrenceId')->andReturn(1); $attendees = new Collection([$attendee1, $attendee2]); @@ -168,12 +170,14 @@ public function testCancelOrderAwaitingOfflinePayment(): void $order->shouldReceive('getLocale')->andReturn('en'); $attendee1 = m::mock(AttendeeDomainObject::class); - $attendee1->shouldReceive('getproductPriceId')->andReturn(1); + $attendee1->shouldReceive('getProductPriceId')->andReturn(1); $attendee1->shouldReceive('getProductId')->andReturn(10); + $attendee1->shouldReceive('getEventOccurrenceId')->andReturn(1); $attendee2 = m::mock(AttendeeDomainObject::class); - $attendee2->shouldReceive('getproductPriceId')->andReturn(2); + $attendee2->shouldReceive('getProductPriceId')->andReturn(2); $attendee2->shouldReceive('getProductId')->andReturn(20); + $attendee2->shouldReceive('getEventOccurrenceId')->andReturn(1); $attendees = new Collection([$attendee1, $attendee2]); diff --git a/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php index 75fe3c4306..91af0cac10 100644 --- a/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php @@ -2,14 +2,19 @@ namespace Tests\Unit\Services\Domain\Order; -use HiEvents\DomainObjects\Enums\ProductPriceType; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\ProductDomainObject; +use HiEvents\DomainObjects\ProductOccurrenceVisibilityDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; -use HiEvents\DomainObjects\Status\EventStatus; +use HiEvents\DomainObjects\Status\EventOccurrenceStatus; +use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; -use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; +use HiEvents\Repository\Interfaces\OrderItemRepositoryInterface; +use HiEvents\Repository\Interfaces\ProductOccurrenceVisibilityRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; +use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; +use HiEvents\Services\Domain\EventOccurrence\OccurrencePurchaseEligibilityService; use HiEvents\Services\Domain\Order\OrderCreateRequestValidationService; use HiEvents\Services\Domain\Product\AvailableProductQuantitiesFetchService; use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesDTO; @@ -23,9 +28,19 @@ class OrderCreateRequestValidationServiceTest extends TestCase { private ProductRepositoryInterface|MockInterface $productRepository; + private PromoCodeRepositoryInterface|MockInterface $promoCodeRepository; + private EventRepositoryInterface|MockInterface $eventRepository; + private AvailableProductQuantitiesFetchService|MockInterface $availabilityService; + + private EventOccurrenceRepositoryInterface|MockInterface $occurrenceRepository; + + private ProductOccurrenceVisibilityRepositoryInterface|MockInterface $visibilityRepository; + + private OrderItemRepositoryInterface|MockInterface $orderItemRepository; + private OrderCreateRequestValidationService $service; protected function setUp(): void @@ -36,175 +51,462 @@ protected function setUp(): void $this->promoCodeRepository = Mockery::mock(PromoCodeRepositoryInterface::class); $this->eventRepository = Mockery::mock(EventRepositoryInterface::class); $this->availabilityService = Mockery::mock(AvailableProductQuantitiesFetchService::class); + $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->visibilityRepository = Mockery::mock(ProductOccurrenceVisibilityRepositoryInterface::class); + $this->orderItemRepository = Mockery::mock(OrderItemRepositoryInterface::class); + + // Default: no visibility rules → all products visible. Individual tests can + // override this expectation when they want to exercise the visibility check. + $this->visibilityRepository + ->shouldReceive('findWhereIn') + ->byDefault() + ->andReturn(collect()); + + // Default: no reserved orders. Tests that exercise capacity-vs-reservation + // logic can override this expectation. + $this->orderItemRepository + ->shouldReceive('getReservedQuantityForOccurrence') + ->byDefault() + ->andReturn(0); + + // Build the real eligibility service from the same mocked repositories + // — keeps the existing tests as integration-style verification that the + // validator + eligibility service compose correctly without doubling up + // on Mockery setup. + $eligibilityService = new OccurrencePurchaseEligibilityService( + $this->occurrenceRepository, + $this->orderItemRepository, + $this->visibilityRepository, + ); $this->service = new OrderCreateRequestValidationService( $this->productRepository, $this->promoCodeRepository, $this->eventRepository, + $this->occurrenceRepository, $this->availabilityService, + $eligibilityService, ); } - protected function tearDown(): void + public function test_rejects_cancelled_occurrence(): void { - Mockery::close(); - parent::tearDown(); + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('cancelled'); + + $occurrence = $this->createOccurrence( + status: EventOccurrenceStatus::CANCELLED->name, + ); + + $this->setupOccurrenceLookup(1, 10, $occurrence); + $this->setupEventLookup(1); + + $this->service->validateRequestData(1, $this->createRequestData(10)); } - public function testZeroQuantityTiersAreSkippedDuringValidation(): void + public function test_rejects_sold_out_occurrence(): void { - $eventId = 1; - $productId = 10; - $selectedPriceId = 101; - $unselectedPriceId = 102; + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('sold out'); - $this->setupMocks( - eventId: $eventId, - productId: $productId, - priceIds: [$selectedPriceId, $unselectedPriceId], - priceLabels: ['Selected Tier', 'Unselected Tier'], - availabilities: [ - ['price_id' => $selectedPriceId, 'quantity_available' => 5, 'quantity_reserved' => 0], - ['price_id' => $unselectedPriceId, 'quantity_available' => 0, 'quantity_reserved' => 0], - ], + $occurrence = $this->createOccurrence( + status: EventOccurrenceStatus::SOLD_OUT->name, ); - $data = [ - 'products' => [ - [ - 'product_id' => $productId, - 'quantities' => [ - ['price_id' => $selectedPriceId, 'quantity' => 1], - ['price_id' => $unselectedPriceId, 'quantity' => 0], - ], - ], - ], - ]; + $this->setupOccurrenceLookup(1, 10, $occurrence); + $this->setupEventLookup(1); + + $this->service->validateRequestData(1, $this->createRequestData(10)); + } + + public function test_rejects_when_occurrence_capacity_exceeded(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('capacity'); + + $occurrence = $this->createOccurrence( + status: EventOccurrenceStatus::ACTIVE->name, + capacity: 10, + usedCapacity: 8, + ); - $this->service->validateRequestData($eventId, $data); + $this->setupOccurrenceLookup(1, 10, $occurrence); + $this->setupEventLookup(1); + + $data = $this->createRequestData(10, quantity: 5); + + $this->service->validateRequestData(1, $data); + } + + public function test_accepts_active_occurrence_with_sufficient_capacity(): void + { + $occurrence = $this->createOccurrence( + status: EventOccurrenceStatus::ACTIVE->name, + capacity: 100, + usedCapacity: 0, + ); + + $this->setupOccurrenceLookup(1, 10, $occurrence); + $this->setupEventLookup(1); + $this->setupAvailability(1); + $this->setupProducts(1, 10, 100); + + $data = $this->createRequestData(10, quantity: 2); + + $this->service->validateRequestData(1, $data); $this->assertTrue(true); } - public function testZeroQuantityTierWithNegativeAvailabilityDoesNotThrow(): void + public function test_normalizes_missing_occurrence_id_for_single_event_checkout(): void { - $eventId = 1; - $productId = 10; - $healthyPriceId = 101; - $brokenPriceId = 102; + $occurrence = $this->createOccurrence( + status: EventOccurrenceStatus::ACTIVE->name, + capacity: 100, + usedCapacity: 0, + ); - $this->setupMocks( - eventId: $eventId, - productId: $productId, - priceIds: [$healthyPriceId, $brokenPriceId], - priceLabels: ['Healthy Tier', 'Broken Tier'], - availabilities: [ - ['price_id' => $healthyPriceId, 'quantity_available' => 10, 'quantity_reserved' => 0], - ['price_id' => $brokenPriceId, 'quantity_available' => -5, 'quantity_reserved' => 0], - ], + $this->setupEventLookup(1, isRecurring: false); + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturn(collect([$occurrence])); + $this->setupOccurrenceLookup(1, 10, $occurrence); + $this->setupAvailability(1); + $this->setupProducts(1, 10, 100); + + $data = $this->createRequestData(10, quantity: 2); + unset($data['products'][0]['event_occurrence_id']); + + $normalized = $this->service->validateRequestData(1, $data); + + $this->assertSame(10, $normalized['products'][0]['event_occurrence_id']); + } + + public function test_accepts_occurrence_with_unlimited_capacity(): void + { + $occurrence = $this->createOccurrence( + status: EventOccurrenceStatus::ACTIVE->name, + capacity: null, + usedCapacity: 0, + ); + + $this->setupOccurrenceLookup(1, 10, $occurrence); + $this->setupEventLookup(1); + $this->setupAvailability(1); + $this->setupProducts(1, 10, 100); + + $data = $this->createRequestData(10, quantity: 5); + + $this->service->validateRequestData(1, $data); + $this->assertTrue(true); + } + + public function test_rejects_when_occurrence_not_found_for_event(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('not found'); + + $this->setupOccurrenceLookup(1, 999, null); + $this->setupEventLookup(1); + + $this->service->validateRequestData(1, $this->createRequestData(999)); + } + + public function test_skips_capacity_assignments_for_recurring_events(): void + { + $occurrence = $this->createOccurrence( + status: EventOccurrenceStatus::ACTIVE->name, + capacity: null, ); + $this->setupOccurrenceLookup(1, 10, $occurrence); + $this->setupEventLookup(1, isRecurring: true); + $this->setupAvailability(1, capacities: collect()); + $this->setupProducts(1, 10, 100); + + $data = $this->createRequestData(10, quantity: 2); + + $this->service->validateRequestData(1, $data); + $this->assertTrue(true); + } + + public function test_rejects_product_hidden_from_occurrence(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('not available for this occurrence'); + + $occurrence = $this->createOccurrence( + status: EventOccurrenceStatus::ACTIVE->name, + capacity: 100, + usedCapacity: 0, + ); + + $this->setupOccurrenceLookup(1, 10, $occurrence); + $this->setupEventLookup(1); + + // Visibility rules exist for occurrence 10 but product 10 is NOT in the visible set, + // so the order must be rejected even though all other validation would pass. + $visibilityRule = (new ProductOccurrenceVisibilityDomainObject) + ->setEventOccurrenceId(10) + ->setProductId(99); + + $this->visibilityRepository + ->shouldReceive('findWhereIn') + ->with('event_occurrence_id', [10]) + ->andReturn(collect([$visibilityRule])); + + $this->service->validateRequestData(1, $this->createRequestData(10)); + } + + public function test_allows_product_explicitly_visible_on_occurrence(): void + { + $occurrence = $this->createOccurrence( + status: EventOccurrenceStatus::ACTIVE->name, + capacity: 100, + usedCapacity: 0, + ); + + $this->setupOccurrenceLookup(1, 10, $occurrence); + $this->setupEventLookup(1); + $this->setupAvailability(1); + $this->setupProducts(1, 10, 100); + + $visibilityRule = (new ProductOccurrenceVisibilityDomainObject) + ->setEventOccurrenceId(10) + ->setProductId(10); + + $this->visibilityRepository + ->shouldReceive('findWhereIn') + ->with('event_occurrence_id', [10]) + ->andReturn(collect([$visibilityRule])); + + $this->service->validateRequestData(1, $this->createRequestData(10)); + $this->assertTrue(true); + } + + /** + * Regression guard for the perf fix: an order that spans multiple occurrences must + * resolve all visibility rules in a single batched query (findWhereIn) instead of + * one query per occurrence (the original N+1 implementation). + */ + public function test_enforces_per_occurrence_visibility_for_multi_occurrence_order(): void + { + // Cart contains product 10 on occurrence 10 and product 20 on occurrence 20. + // Visibility allows product 10 on occurrence 10 but blocks product 20 on + // occurrence 20 — processing reaches the second occurrence and throws. + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('not available for this occurrence'); + + $occurrence10 = $this->createOccurrence( + status: EventOccurrenceStatus::ACTIVE->name, + capacity: 100, + ); + + $occurrence20 = (new EventOccurrenceDomainObject) + ->setId(20) + ->setEventId(1) + ->setStatus(EventOccurrenceStatus::ACTIVE->name) + ->setCapacity(100) + ->setUsedCapacity(0) + ->setStartDate('2026-07-15 10:00:00'); + + $this->setupOccurrenceLookup(1, 10, $occurrence10); + $this->setupOccurrenceLookup(1, 20, $occurrence20); + $this->setupEventLookup(1); + + $rule10 = (new ProductOccurrenceVisibilityDomainObject) + ->setEventOccurrenceId(10) + ->setProductId(10); + $rule20 = (new ProductOccurrenceVisibilityDomainObject) + ->setEventOccurrenceId(20) + ->setProductId(99); + + // OccurrencePurchaseEligibilityService asks for one occurrence at a time — + // simpler API at the cost of N visibility lookups. Acceptable trade-off + // for the manual-attendee path; revisit if multi-occurrence orders become + // a hot path. + $this->visibilityRepository + ->shouldReceive('findWhereIn') + ->with('event_occurrence_id', [10]) + ->andReturn(collect([$rule10])); + $this->visibilityRepository + ->shouldReceive('findWhereIn') + ->with('event_occurrence_id', [20]) + ->andReturn(collect([$rule20])); + $data = [ 'products' => [ [ - 'product_id' => $productId, - 'quantities' => [ - ['price_id' => $healthyPriceId, 'quantity' => 1], - ['price_id' => $brokenPriceId, 'quantity' => 0], - ], + 'product_id' => 10, + 'event_occurrence_id' => 10, + 'quantities' => [['price_id' => 100, 'quantity' => 1]], + ], + [ + 'product_id' => 20, + 'event_occurrence_id' => 20, + 'quantities' => [['price_id' => 200, 'quantity' => 1]], ], ], ]; - $this->service->validateRequestData($eventId, $data); - $this->assertTrue(true); + $this->service->validateRequestData(1, $data); } - public function testNonZeroQuantityStillValidatesAgainstAvailability(): void + public function test_allows_all_products_when_no_visibility_rules(): void { - $eventId = 1; - $productId = 10; - $priceId = 101; - - $this->setupMocks( - eventId: $eventId, - productId: $productId, - priceIds: [$priceId], - priceLabels: ['Test Tier'], - availabilities: [ - ['price_id' => $priceId, 'quantity_available' => 2, 'quantity_reserved' => 0], - ], + $occurrence10 = $this->createOccurrence( + status: EventOccurrenceStatus::ACTIVE->name, + capacity: 100, ); + $occurrence20 = (new EventOccurrenceDomainObject) + ->setId(20) + ->setEventId(1) + ->setStatus(EventOccurrenceStatus::ACTIVE->name) + ->setCapacity(100) + ->setUsedCapacity(0) + ->setStartDate('2026-08-01 10:00:00'); + $this->setupOccurrenceLookup(1, 10, $occurrence10); + $this->setupOccurrenceLookup(1, 20, $occurrence20); + $this->setupEventLookup(1); + $this->setupAvailability(1); + $this->setupProducts(1, 10, 100); + + // No visibility rules for either occurrence → both products allowed. + $this->visibilityRepository + ->shouldReceive('findWhereIn') + ->with('event_occurrence_id', [10]) + ->andReturn(collect()); + $this->visibilityRepository + ->shouldReceive('findWhereIn') + ->with('event_occurrence_id', [20]) + ->andReturn(collect()); + + // Product 10 sells on both occurrences in this scenario; product details are + // identical so the existing single-product setup is sufficient. $data = [ 'products' => [ [ - 'product_id' => $productId, - 'quantities' => [ - ['price_id' => $priceId, 'quantity' => 5], - ], + 'product_id' => 10, + 'event_occurrence_id' => 10, + 'quantities' => [['price_id' => 100, 'quantity' => 1]], + ], + [ + 'product_id' => 10, + 'event_occurrence_id' => 20, + 'quantities' => [['price_id' => 100, 'quantity' => 1]], ], ], ]; - $this->expectException(ValidationException::class); - $this->service->validateRequestData($eventId, $data); + $this->service->validateRequestData(1, $data); + $this->assertTrue(true); + } + + private function createOccurrence( + string $status = 'ACTIVE', + ?int $capacity = null, + int $usedCapacity = 0, + ): EventOccurrenceDomainObject { + return (new EventOccurrenceDomainObject) + ->setId(10) + ->setEventId(1) + ->setStatus($status) + ->setCapacity($capacity) + ->setUsedCapacity($usedCapacity) + ->setStartDate('2026-06-15 10:00:00'); + } + + private function setupOccurrenceLookup(int $eventId, int $occurrenceId, ?EventOccurrenceDomainObject $occurrence): void + { + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->with([ + 'id' => $occurrenceId, + 'event_id' => $eventId, + ]) + ->andReturn($occurrence); } - private function setupMocks( - int $eventId, - int $productId, - array $priceIds, - array $priceLabels, - array $availabilities, - ): void + private function setupEventLookup(int $eventId, bool $isRecurring = false): void { $event = Mockery::mock(EventDomainObject::class); $event->shouldReceive('getId')->andReturn($eventId); - $event->shouldReceive('getStatus')->andReturn(EventStatus::LIVE->name); - $event->shouldReceive('getCurrency')->andReturn('USD'); + $event->shouldReceive('isRecurring')->andReturn($isRecurring); - $this->eventRepository->shouldReceive('findById')->with($eventId)->andReturn($event); + $this->eventRepository + ->shouldReceive('findById') + ->with($eventId) + ->andReturn($event); + } - $productPrices = new Collection(); - foreach ($priceIds as $i => $priceId) { - $price = Mockery::mock(ProductPriceDomainObject::class); - $price->shouldReceive('getId')->andReturn($priceId); - $price->shouldReceive('getLabel')->andReturn($priceLabels[$i] ?? null); - $productPrices->push($price); - } + private function setupAvailability(int $eventId, ?Collection $capacities = null, int $available = 100): void + { + $this->availabilityService + ->shouldReceive('getAvailableProductQuantities') + ->andReturn(new AvailableProductQuantitiesResponseDTO( + productQuantities: collect([ + AvailableProductQuantitiesDTO::fromArray([ + 'product_id' => 10, + 'price_id' => 100, + 'product_title' => 'Test Product', + 'price_label' => null, + 'quantity_available' => $available, + 'quantity_reserved' => 0, + 'initial_quantity_available' => 100, + 'capacities' => new Collection, + ]), + ]), + capacities: $capacities ?? collect(), + )); + } + + private function setupProducts(int $eventId, int $productId, int $priceId): void + { + $price = Mockery::mock(ProductPriceDomainObject::class); + $price->shouldReceive('getId')->andReturn($priceId); $product = Mockery::mock(ProductDomainObject::class); $product->shouldReceive('getId')->andReturn($productId); $product->shouldReceive('getEventId')->andReturn($eventId); $product->shouldReceive('getTitle')->andReturn('Test Product'); - $product->shouldReceive('getMaxPerOrder')->andReturn(100); + $product->shouldReceive('getMaxPerOrder')->andReturn(10); $product->shouldReceive('getMinPerOrder')->andReturn(1); + $product->shouldReceive('getType')->andReturn('PAID'); + $product->shouldReceive('getPrice')->andReturn(10.0); $product->shouldReceive('isSoldOut')->andReturn(false); - $product->shouldReceive('getType')->andReturn(ProductPriceType::TIERED->name); - $product->shouldReceive('getProductPrices')->andReturn($productPrices); - - $this->productRepository->shouldReceive('loadRelation')->andReturnSelf(); - $this->productRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$product])); - - $quantityDTOs = collect(); - foreach ($availabilities as $avail) { - $quantityDTOs->push(AvailableProductQuantitiesDTO::fromArray([ - 'product_id' => $productId, - 'price_id' => $avail['price_id'], - 'product_title' => 'Test Product', - 'price_label' => null, - 'quantity_available' => $avail['quantity_available'], - 'quantity_reserved' => $avail['quantity_reserved'], - 'initial_quantity_available' => 100, - 'capacities' => collect(), - ])); - } - - $this->availabilityService->shouldReceive('getAvailableProductQuantities') - ->with($eventId, Mockery::any()) - ->andReturn(new AvailableProductQuantitiesResponseDTO( - productQuantities: $quantityDTOs, - capacities: collect(), - )); + $product->shouldReceive('getProductPrices')->andReturn(collect([$price])); + $product->shouldReceive('getProductType')->andReturn('TICKET'); + + $this->productRepository + ->shouldReceive('loadRelation')->andReturnSelf(); + + $this->productRepository + ->shouldReceive('findWhereIn') + ->andReturn(collect([$product])); + } + + private function createRequestData(int $occurrenceId, int $productId = 10, int $priceId = 100, int $quantity = 1): array + { + return [ + 'products' => [ + [ + 'product_id' => $productId, + 'event_occurrence_id' => $occurrenceId, + 'quantities' => [ + [ + 'price_id' => $priceId, + 'quantity' => $quantity, + ], + ], + ], + ], + ]; + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); } } diff --git a/backend/tests/Unit/Services/Domain/Order/OrderPlatformFeePassThroughServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderPlatformFeePassThroughServiceTest.php index 202b6a7b52..c11c966ad9 100644 --- a/backend/tests/Unit/Services/Domain/Order/OrderPlatformFeePassThroughServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Order/OrderPlatformFeePassThroughServiceTest.php @@ -3,7 +3,7 @@ namespace Tests\Unit\Services\Domain\Order; use Brick\Money\Currency; -use HiEvents\DomainObjects\AccountConfigurationDomainObject; +use HiEvents\DomainObjects\OrganizerConfigurationDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\Services\Domain\Order\OrderPlatformFeePassThroughService; use HiEvents\Services\Infrastructure\CurrencyConversion\CurrencyConversionClientInterface; @@ -32,9 +32,9 @@ protected function setUp(): void ); } - private function createAccountConfig(float $fixedFee = 0.30, float $percentageFee = 2.9, string $currency = 'USD'): AccountConfigurationDomainObject + private function createAccountConfig(float $fixedFee = 0.30, float $percentageFee = 2.9, string $currency = 'USD'): OrganizerConfigurationDomainObject { - $mock = $this->createMock(AccountConfigurationDomainObject::class); + $mock = $this->createMock(OrganizerConfigurationDomainObject::class); $mock->method('getFixedApplicationFee')->willReturn($fixedFee); $mock->method('getPercentageApplicationFee')->willReturn($percentageFee); $mock->method('getApplicationFeeCurrency')->willReturn($currency); diff --git a/backend/tests/Unit/Services/Domain/Payment/Stripe/StripeAccountSyncServiceTest.php b/backend/tests/Unit/Services/Domain/Payment/Stripe/StripeAccountSyncServiceTest.php index b31d1814c7..e0ca179d66 100644 --- a/backend/tests/Unit/Services/Domain/Payment/Stripe/StripeAccountSyncServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Payment/Stripe/StripeAccountSyncServiceTest.php @@ -2,11 +2,11 @@ namespace Tests\Unit\Services\Domain\Payment\Stripe; -use HiEvents\DomainObjects\AccountDomainObject; -use HiEvents\DomainObjects\AccountStripePlatformDomainObject; +use HiEvents\DomainObjects\Generated\OrganizerStripePlatformDomainObjectAbstract; use HiEvents\Repository\Interfaces\AccountRepositoryInterface; -use HiEvents\Repository\Interfaces\AccountStripePlatformRepositoryInterface; -use HiEvents\Repository\Interfaces\AccountVatSettingRepositoryInterface; +use HiEvents\Repository\Interfaces\OrganizerVatSettingRepositoryInterface; +use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; +use HiEvents\Repository\Interfaces\OrganizerStripePlatformRepositoryInterface; use HiEvents\Services\Domain\Payment\Stripe\StripeAccountSyncService; use Illuminate\Config\Repository; use Mockery as m; @@ -19,8 +19,9 @@ class StripeAccountSyncServiceTest extends TestCase private StripeAccountSyncService $service; private LoggerInterface $logger; private AccountRepositoryInterface $accountRepository; - private AccountStripePlatformRepositoryInterface $accountStripePlatformRepository; - private AccountVatSettingRepositoryInterface $vatSettingRepository; + private OrganizerRepositoryInterface $organizerRepository; + private OrganizerStripePlatformRepositoryInterface $organizerStripePlatformRepository; + private OrganizerVatSettingRepositoryInterface $vatSettingRepository; private Repository $config; protected function setUp(): void @@ -29,14 +30,16 @@ protected function setUp(): void $this->logger = m::mock(LoggerInterface::class); $this->accountRepository = m::mock(AccountRepositoryInterface::class); - $this->accountStripePlatformRepository = m::mock(AccountStripePlatformRepositoryInterface::class); - $this->vatSettingRepository = m::mock(AccountVatSettingRepositoryInterface::class); + $this->organizerRepository = m::mock(OrganizerRepositoryInterface::class); + $this->organizerStripePlatformRepository = m::mock(OrganizerStripePlatformRepositoryInterface::class); + $this->vatSettingRepository = m::mock(OrganizerVatSettingRepositoryInterface::class); $this->config = m::mock(Repository::class); $this->service = new StripeAccountSyncService( $this->logger, $this->accountRepository, - $this->accountStripePlatformRepository, + $this->organizerRepository, + $this->organizerStripePlatformRepository, $this->vatSettingRepository, $this->config, ); @@ -48,42 +51,50 @@ public function testIsStripeAccountCompleteReturnsTrueWhenBothEnabled(): void $stripeAccount->charges_enabled = true; $stripeAccount->payouts_enabled = true; - $result = $this->service->isStripeAccountComplete($stripeAccount); - - $this->assertTrue($result); + $this->assertTrue($this->service->isStripeAccountComplete($stripeAccount)); } - public function testIsStripeAccountCompleteReturnsFalseWhenChargesDisabled(): void + public function testIsStripeAccountCompleteReturnsFalseWhenAnythingDisabled(): void { - $stripeAccount = new Account(); - $stripeAccount->charges_enabled = false; - $stripeAccount->payouts_enabled = true; - - $result = $this->service->isStripeAccountComplete($stripeAccount); - - $this->assertFalse($result); + foreach ([[false, true], [true, false], [false, false]] as [$charges, $payouts]) { + $stripeAccount = new Account(); + $stripeAccount->charges_enabled = $charges; + $stripeAccount->payouts_enabled = $payouts; + $this->assertFalse($this->service->isStripeAccountComplete($stripeAccount)); + } } - public function testIsStripeAccountCompleteReturnsFalseWhenPayoutsDisabled(): void + public function testSyncByAccountIdUpdatesAllOrganizerRowsAndStopsIfIncomplete(): void { - $stripeAccount = new Account(); - $stripeAccount->charges_enabled = true; - $stripeAccount->payouts_enabled = false; - - $result = $this->service->isStripeAccountComplete($stripeAccount); - - $this->assertFalse($result); - } - - public function testIsStripeAccountCompleteReturnsFalseWhenBothDisabled(): void - { - $stripeAccount = new Account(); - $stripeAccount->charges_enabled = false; - $stripeAccount->payouts_enabled = false; - - $result = $this->service->isStripeAccountComplete($stripeAccount); - - $this->assertFalse($result); + $stripeAccount = Account::constructFrom([ + 'id' => 'acct_123', + 'charges_enabled' => false, + 'payouts_enabled' => false, + 'country' => 'US', + 'type' => 'standard', + 'business_type' => 'individual', + 'capabilities' => [], + 'requirements' => [ + 'currently_due' => ['external_account'], + 'eventually_due' => [], + 'past_due' => [], + 'pending_verification' => [], + ], + ]); + + $this->organizerStripePlatformRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + m::on(fn($attrs) => array_key_exists(OrganizerStripePlatformDomainObjectAbstract::STRIPE_SETUP_COMPLETED_AT, $attrs) + && $attrs[OrganizerStripePlatformDomainObjectAbstract::STRIPE_SETUP_COMPLETED_AT] === null), + [OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => 'acct_123'], + ) + ->andReturn(2); + + $this->service->syncStripeAccountStatusByAccountId($stripeAccount); + + $this->addToAssertionCount(1); } protected function tearDown(): void diff --git a/backend/tests/Unit/Services/Domain/Product/ProductPriceServiceTest.php b/backend/tests/Unit/Services/Domain/Product/ProductPriceServiceTest.php new file mode 100644 index 0000000000..4b346e2d39 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Product/ProductPriceServiceTest.php @@ -0,0 +1,139 @@ +priceOverrideRepository = Mockery::mock(ProductPriceOccurrenceOverrideRepositoryInterface::class); + $this->service = new ProductPriceService($this->priceOverrideRepository); + } + + public function testGetPriceUsesOverrideWhenPresent(): void + { + $product = $this->createProduct(ProductPriceType::PAID->name, 50.00); + $orderDetail = new OrderProductPriceDTO(quantity: 1, price_id: 100); + + $override = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class); + $override->shouldReceive('getPrice')->andReturn('35.00'); + + $this->priceOverrideRepository + ->shouldReceive('findFirstWhere') + ->with([ + 'event_occurrence_id' => 5, + 'product_price_id' => 100, + ]) + ->andReturn($override); + + $result = $this->service->getPrice($product, $orderDetail, null, 5); + + $this->assertEquals(35.00, $result->price); + } + + public function testGetPriceFallsBackToBaseWhenNoOverride(): void + { + $product = $this->createProduct(ProductPriceType::PAID->name, 50.00); + $orderDetail = new OrderProductPriceDTO(quantity: 1, price_id: 100); + + $this->priceOverrideRepository + ->shouldReceive('findFirstWhere') + ->with([ + 'event_occurrence_id' => 5, + 'product_price_id' => 100, + ]) + ->andReturn(null); + + $result = $this->service->getPrice($product, $orderDetail, null, 5); + + $this->assertEquals(50.00, $result->price); + } + + public function testGetPriceSkipsOverrideLookupWithoutOccurrence(): void + { + $product = $this->createProduct(ProductPriceType::PAID->name, 50.00); + $orderDetail = new OrderProductPriceDTO(quantity: 1, price_id: 100); + + $this->priceOverrideRepository->shouldNotReceive('findFirstWhere'); + + $result = $this->service->getPrice($product, $orderDetail, null); + + $this->assertEquals(50.00, $result->price); + } + + public function testGetPriceAppliesPromoCodeAfterOverride(): void + { + $product = $this->createProduct(ProductPriceType::PAID->name, 50.00); + $orderDetail = new OrderProductPriceDTO(quantity: 1, price_id: 100); + + $override = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class); + $override->shouldReceive('getPrice')->andReturn('40.00'); + + $this->priceOverrideRepository + ->shouldReceive('findFirstWhere') + ->andReturn($override); + + $promoCode = Mockery::mock(PromoCodeDomainObject::class); + $promoCode->shouldReceive('appliesToProduct')->andReturn(true); + $promoCode->shouldReceive('getDiscountType')->andReturn(PromoCodeDiscountTypeEnum::PERCENTAGE->name); + $promoCode->shouldReceive('isFixedDiscount')->andReturn(false); + $promoCode->shouldReceive('isPercentageDiscount')->andReturn(true); + $promoCode->shouldReceive('getDiscount')->andReturn(10); + + $result = $this->service->getPrice($product, $orderDetail, $promoCode, 5); + + $this->assertEquals(36.00, $result->price); + $this->assertEquals(40.00, $result->price_before_discount); + } + + public function testGetPriceReturnsFreeForFreeProduct(): void + { + $product = $this->createProduct(ProductPriceType::FREE->name, 0.0); + $orderDetail = new OrderProductPriceDTO(quantity: 1, price_id: 100); + + $this->priceOverrideRepository->shouldReceive('findFirstWhere')->andReturn(null); + + $result = $this->service->getPrice($product, $orderDetail, null, 5); + + $this->assertEquals(0.00, $result->price); + } + + private function createProduct(string $type, float $price): ProductDomainObject + { + $productPrice = Mockery::mock(ProductPriceDomainObject::class); + $productPrice->shouldReceive('getId')->andReturn(100); + $productPrice->shouldReceive('getPrice')->andReturn($price); + + $product = Mockery::mock(ProductDomainObject::class); + $product->shouldReceive('getType')->andReturn($type); + $product->shouldReceive('getPrice')->andReturn($price); + $product->shouldReceive('getProductPrices')->andReturn(collect([$productPrice])); + $product->shouldReceive('getPriceById')->with(100)->andReturn($productPrice); + + return $product; + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Domain/Product/ProductQuantityUpdateServiceTest.php b/backend/tests/Unit/Services/Domain/Product/ProductQuantityUpdateServiceTest.php new file mode 100644 index 0000000000..402407459a --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Product/ProductQuantityUpdateServiceTest.php @@ -0,0 +1,481 @@ +productPriceRepository = Mockery::mock(ProductPriceRepositoryInterface::class); + $this->productRepository = Mockery::mock(ProductRepositoryInterface::class); + $this->capacityAssignmentRepository = Mockery::mock(CapacityAssignmentRepositoryInterface::class); + $this->databaseManager = Mockery::mock(DatabaseManager::class); + $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + + $this->databaseManager->shouldReceive('transaction') + ->andReturnUsing(fn($callback) => $callback()); + + $this->service = new ProductQuantityUpdateService( + $this->productPriceRepository, + $this->productRepository, + $this->capacityAssignmentRepository, + $this->databaseManager, + $this->occurrenceRepository, + ); + } + + public function testIncreaseQuantitySoldIncrementsOccurrenceCapacity(): void + { + $priceId = 100; + $occurrenceId = 5; + $adjustment = 2; + + $price = Mockery::mock(ProductPriceDomainObject::class); + $price->shouldReceive('getProductId')->andReturn(10); + + $this->productPriceRepository + ->shouldReceive('findFirstWhere') + ->with(['id' => $priceId]) + ->andReturn($price); + + $this->productRepository + ->shouldReceive('getCapacityAssignmentsByProductId') + ->with(10) + ->andReturn(collect()); + + $this->productPriceRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + Mockery::on(fn($data) => array_key_exists('quantity_sold', $data)), + ['id' => $priceId], + ); + + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + Mockery::on(fn($data) => array_key_exists('used_capacity', $data)), + ['id' => $occurrenceId], + ); + + $occurrence = (new EventOccurrenceDomainObject()) + ->setId($occurrenceId) + ->setCapacity(null) + ->setUsedCapacity($adjustment) + ->setStatus(EventOccurrenceStatus::ACTIVE->name); + + $this->occurrenceRepository + ->shouldReceive('findById') + ->with($occurrenceId) + ->andReturn($occurrence); + + $this->service->increaseQuantitySold($priceId, $adjustment, $occurrenceId); + } + + public function testDecreaseQuantitySoldDecrementsOccurrenceCapacity(): void + { + $priceId = 100; + $occurrenceId = 5; + $adjustment = 1; + + $price = Mockery::mock(ProductPriceDomainObject::class); + $price->shouldReceive('getProductId')->andReturn(10); + + $this->productPriceRepository + ->shouldReceive('findFirstWhere') + ->with(['id' => $priceId]) + ->andReturn($price); + + $this->productRepository + ->shouldReceive('getCapacityAssignmentsByProductId') + ->with(10) + ->andReturn(collect()); + + $this->productPriceRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + Mockery::on(fn($data) => array_key_exists('quantity_sold', $data)), + ['id' => $priceId], + ); + + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + Mockery::on(fn($data) => array_key_exists('used_capacity', $data)), + ['id' => $occurrenceId], + ); + + $occurrence = (new EventOccurrenceDomainObject()) + ->setId($occurrenceId) + ->setCapacity(10) + ->setUsedCapacity(5) + ->setStatus(EventOccurrenceStatus::ACTIVE->name); + + $this->occurrenceRepository + ->shouldReceive('findById') + ->with($occurrenceId) + ->andReturn($occurrence); + + $this->service->decreaseQuantitySold($priceId, $adjustment, $occurrenceId); + } + + public function testIncreaseQuantitySoldSkipsOccurrenceWhenNull(): void + { + $priceId = 100; + + $price = Mockery::mock(ProductPriceDomainObject::class); + $price->shouldReceive('getProductId')->andReturn(10); + + $this->productPriceRepository + ->shouldReceive('findFirstWhere') + ->andReturn($price); + + $this->productRepository + ->shouldReceive('getCapacityAssignmentsByProductId') + ->andReturn(collect()); + + $priceUpdateCalled = false; + $this->productPriceRepository + ->shouldReceive('updateWhere') + ->once() + ->andReturnUsing(function () use (&$priceUpdateCalled) { + $priceUpdateCalled = true; + return 1; + }); + + $this->occurrenceRepository + ->shouldNotReceive('updateWhere'); + + $this->service->increaseQuantitySold($priceId, 1, null); + + $this->assertTrue($priceUpdateCalled); + } + + public function testUpdateQuantitiesFromOrderPassesOccurrenceId(): void + { + $orderItem = (new OrderItemDomainObject()) + ->setId(1) + ->setProductPriceId(100) + ->setQuantity(2) + ->setEventOccurrenceId(5); + + $order = (new OrderDomainObject()) + ->setOrderItems(new Collection([$orderItem])); + + $price = Mockery::mock(ProductPriceDomainObject::class); + $price->shouldReceive('getProductId')->andReturn(10); + + $this->productPriceRepository + ->shouldReceive('findFirstWhere') + ->with(['id' => 100]) + ->andReturn($price); + + $this->productRepository + ->shouldReceive('getCapacityAssignmentsByProductId') + ->with(10) + ->andReturn(collect()); + + $this->productPriceRepository + ->shouldReceive('updateWhere') + ->once(); + + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->once() + ->with( + Mockery::on(fn($data) => array_key_exists('used_capacity', $data)), + ['id' => 5], + ); + + $occurrence = (new EventOccurrenceDomainObject()) + ->setId(5) + ->setCapacity(null) + ->setUsedCapacity(2) + ->setStatus(EventOccurrenceStatus::ACTIVE->name); + + $this->occurrenceRepository + ->shouldReceive('findById') + ->with(5) + ->andReturn($occurrence); + + $this->service->updateQuantitiesFromOrder($order); + } + + public function testIncreaseQuantitySoldSetsOccurrenceToSoldOutWhenAtCapacity(): void + { + $priceId = 100; + $occurrenceId = 5; + + $price = Mockery::mock(ProductPriceDomainObject::class); + $price->shouldReceive('getProductId')->andReturn(10); + + $this->productPriceRepository + ->shouldReceive('findFirstWhere') + ->with(['id' => $priceId]) + ->andReturn($price); + + $this->productRepository + ->shouldReceive('getCapacityAssignmentsByProductId') + ->with(10) + ->andReturn(collect()); + + $this->productPriceRepository + ->shouldReceive('updateWhere') + ->once(); + + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->with( + Mockery::on(fn($data) => array_key_exists('used_capacity', $data)), + ['id' => $occurrenceId], + ) + ->once(); + + $occurrence = (new EventOccurrenceDomainObject()) + ->setId($occurrenceId) + ->setCapacity(10) + ->setUsedCapacity(10) + ->setStatus(EventOccurrenceStatus::ACTIVE->name); + + $this->occurrenceRepository + ->shouldReceive('findById') + ->with($occurrenceId) + ->andReturn($occurrence); + + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->with( + ['status' => EventOccurrenceStatus::SOLD_OUT->name], + ['id' => $occurrenceId], + ) + ->once(); + + $this->service->increaseQuantitySold($priceId, 1, $occurrenceId); + } + + public function testIncreaseQuantitySoldDoesNotSetSoldOutWhenCapacityIsNull(): void + { + $priceId = 100; + $occurrenceId = 5; + + $price = Mockery::mock(ProductPriceDomainObject::class); + $price->shouldReceive('getProductId')->andReturn(10); + + $this->productPriceRepository + ->shouldReceive('findFirstWhere') + ->with(['id' => $priceId]) + ->andReturn($price); + + $this->productRepository + ->shouldReceive('getCapacityAssignmentsByProductId') + ->with(10) + ->andReturn(collect()); + + $this->productPriceRepository + ->shouldReceive('updateWhere') + ->once(); + + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->with( + Mockery::on(fn($data) => array_key_exists('used_capacity', $data)), + ['id' => $occurrenceId], + ) + ->once(); + + $occurrence = (new EventOccurrenceDomainObject()) + ->setId($occurrenceId) + ->setCapacity(null) + ->setUsedCapacity(100) + ->setStatus(EventOccurrenceStatus::ACTIVE->name); + + $this->occurrenceRepository + ->shouldReceive('findById') + ->with($occurrenceId) + ->andReturn($occurrence); + + $this->service->increaseQuantitySold($priceId, 1, $occurrenceId); + } + + public function testDecreaseQuantitySoldResetsOccurrenceFromSoldOutToActive(): void + { + $priceId = 100; + $occurrenceId = 5; + + $price = Mockery::mock(ProductPriceDomainObject::class); + $price->shouldReceive('getProductId')->andReturn(10); + + $this->productPriceRepository + ->shouldReceive('findFirstWhere') + ->with(['id' => $priceId]) + ->andReturn($price); + + $this->productRepository + ->shouldReceive('getCapacityAssignmentsByProductId') + ->with(10) + ->andReturn(collect()); + + $this->productPriceRepository + ->shouldReceive('updateWhere') + ->once(); + + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->with( + Mockery::on(fn($data) => array_key_exists('used_capacity', $data)), + ['id' => $occurrenceId], + ) + ->once(); + + $occurrence = (new EventOccurrenceDomainObject()) + ->setId($occurrenceId) + ->setCapacity(10) + ->setUsedCapacity(9) + ->setStatus(EventOccurrenceStatus::SOLD_OUT->name); + + $this->occurrenceRepository + ->shouldReceive('findById') + ->with($occurrenceId) + ->andReturn($occurrence); + + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->with( + ['status' => EventOccurrenceStatus::ACTIVE->name], + ['id' => $occurrenceId], + ) + ->once(); + + $this->service->decreaseQuantitySold($priceId, 1, $occurrenceId); + } + + public function testDecreaseQuantitySoldDoesNotResetNonSoldOutOccurrence(): void + { + $priceId = 100; + $occurrenceId = 5; + + $price = Mockery::mock(ProductPriceDomainObject::class); + $price->shouldReceive('getProductId')->andReturn(10); + + $this->productPriceRepository + ->shouldReceive('findFirstWhere') + ->with(['id' => $priceId]) + ->andReturn($price); + + $this->productRepository + ->shouldReceive('getCapacityAssignmentsByProductId') + ->with(10) + ->andReturn(collect()); + + $this->productPriceRepository + ->shouldReceive('updateWhere') + ->once(); + + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->with( + Mockery::on(fn($data) => array_key_exists('used_capacity', $data)), + ['id' => $occurrenceId], + ) + ->once(); + + $occurrence = (new EventOccurrenceDomainObject()) + ->setId($occurrenceId) + ->setCapacity(10) + ->setUsedCapacity(5) + ->setStatus(EventOccurrenceStatus::ACTIVE->name); + + $this->occurrenceRepository + ->shouldReceive('findById') + ->with($occurrenceId) + ->andReturn($occurrence); + + $this->service->decreaseQuantitySold($priceId, 1, $occurrenceId); + } + + public function testIncreaseQuantitySoldDoesNotOverrideCancelledStatus(): void + { + $priceId = 100; + $occurrenceId = 5; + + $price = Mockery::mock(ProductPriceDomainObject::class); + $price->shouldReceive('getProductId')->andReturn(10); + + $this->productPriceRepository + ->shouldReceive('findFirstWhere') + ->with(['id' => $priceId]) + ->andReturn($price); + + $this->productRepository + ->shouldReceive('getCapacityAssignmentsByProductId') + ->with(10) + ->andReturn(collect()); + + $this->productPriceRepository + ->shouldReceive('updateWhere') + ->once(); + + $this->occurrenceRepository + ->shouldReceive('updateWhere') + ->with( + Mockery::on(fn($data) => array_key_exists('used_capacity', $data)), + ['id' => $occurrenceId], + ) + ->once(); + + $occurrence = (new EventOccurrenceDomainObject()) + ->setId($occurrenceId) + ->setCapacity(10) + ->setUsedCapacity(10) + ->setStatus(EventOccurrenceStatus::CANCELLED->name); + + $this->occurrenceRepository + ->shouldReceive('findById') + ->with($occurrenceId) + ->andReturn($occurrence); + + $this->occurrenceRepository + ->shouldNotReceive('updateWhere') + ->with( + ['status' => EventOccurrenceStatus::SOLD_OUT->name], + ['id' => $occurrenceId], + ); + + $this->service->increaseQuantitySold($priceId, 1, $occurrenceId); + } + + protected function tearDown(): void + { + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Domain/Report/ReportServiceTest.php b/backend/tests/Unit/Services/Domain/Report/ReportServiceTest.php new file mode 100644 index 0000000000..16f356902d --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Report/ReportServiceTest.php @@ -0,0 +1,165 @@ +cache = Mockery::mock(CacheRepository::class); + $this->queryBuilder = Mockery::mock(DatabaseManager::class); + $this->eventRepository = Mockery::mock(EventRepositoryInterface::class); + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getTimezone')->andReturn('UTC'); + + $this->eventRepository->shouldReceive('findById')->with(1)->andReturn($event); + } + + private function setupCachePassthrough(): void + { + $this->cache->shouldReceive('remember') + ->andReturnUsing(fn($key, $ttl, $callback) => $callback()); + } + + public function testProductSalesReportGeneratesWithoutOccurrence(): void + { + $this->setupCachePassthrough(); + $this->queryBuilder->shouldReceive('select') + ->once() + ->with(Mockery::on(fn($sql) => str_contains($sql, 'filtered_orders') && !str_contains($sql, ':occurrence_id')), ['event_id' => 1]) + ->andReturn([]); + + $report = new ProductSalesReport($this->cache, $this->queryBuilder, $this->eventRepository); + $result = $report->generateReport(1, Carbon::now()->subDays(30), Carbon::now()); + + $this->assertCount(0, $result); + } + + public function testProductSalesReportGeneratesWithOccurrence(): void + { + $this->setupCachePassthrough(); + $this->queryBuilder->shouldReceive('select') + ->once() + ->with( + Mockery::on(fn($sql) => str_contains($sql, ':occurrence_id')), + ['event_id' => 1, 'occurrence_id' => 10], + ) + ->andReturn([]); + + $report = new ProductSalesReport($this->cache, $this->queryBuilder, $this->eventRepository); + $result = $report->generateReport(1, Carbon::now()->subDays(30), Carbon::now(), occurrenceId: 10); + + $this->assertCount(0, $result); + } + + public function testDailySalesReportUsesEventDailyStatsWithoutOccurrence(): void + { + $this->setupCachePassthrough(); + $this->queryBuilder->shouldReceive('select') + ->once() + ->with( + Mockery::on(fn($sql) => str_contains($sql, 'event_daily_statistics') && !str_contains($sql, 'event_occurrence_daily_statistics')), + ['event_id' => 1], + ) + ->andReturn([]); + + $report = new DailySalesReport($this->cache, $this->queryBuilder, $this->eventRepository); + $result = $report->generateReport(1, Carbon::now()->subDays(7), Carbon::now()); + + $this->assertCount(0, $result); + } + + public function testDailySalesReportUsesOccurrenceDailyStatsWithOccurrence(): void + { + $this->setupCachePassthrough(); + $this->queryBuilder->shouldReceive('select') + ->once() + ->with( + Mockery::on(fn($sql) => str_contains($sql, 'event_occurrence_daily_statistics') && str_contains($sql, ':occurrence_id')), + ['event_id' => 1, 'occurrence_id' => 10], + ) + ->andReturn([]); + + $report = new DailySalesReport($this->cache, $this->queryBuilder, $this->eventRepository); + $result = $report->generateReport(1, Carbon::now()->subDays(7), Carbon::now(), occurrenceId: 10); + + $this->assertCount(0, $result); + } + + public function testPromoCodesReportGeneratesWithOccurrence(): void + { + $this->setupCachePassthrough(); + $this->queryBuilder->shouldReceive('select') + ->once() + ->with( + Mockery::on(fn($sql) => str_contains($sql, ':occurrence_id')), + ['event_id' => 1, 'occurrence_id' => 10], + ) + ->andReturn([]); + + $report = new PromoCodesReport($this->cache, $this->queryBuilder, $this->eventRepository); + $result = $report->generateReport(1, Carbon::now()->subDays(30), Carbon::now(), occurrenceId: 10); + + $this->assertCount(0, $result); + } + + public function testPromoCodesReportGeneratesWithoutOccurrence(): void + { + $this->setupCachePassthrough(); + $this->queryBuilder->shouldReceive('select') + ->once() + ->with(Mockery::on(fn($sql) => !str_contains($sql, ':occurrence_id')), ['event_id' => 1]) + ->andReturn([]); + + $report = new PromoCodesReport($this->cache, $this->queryBuilder, $this->eventRepository); + $result = $report->generateReport(1, Carbon::now()->subDays(30), Carbon::now()); + + $this->assertCount(0, $result); + } + + public function testOccurrenceSummaryReportGenerates(): void + { + $this->setupCachePassthrough(); + $this->queryBuilder->shouldReceive('select') + ->once() + ->with( + Mockery::on(fn($sql) => str_contains($sql, 'event_occurrences') && str_contains($sql, 'event_occurrence_statistics')), + Mockery::on(fn($bindings) => $bindings['event_id'] === 1 + && isset($bindings['start_date']) + && isset($bindings['end_date'])), + ) + ->andReturn([ + (object) ['occurrence_id' => 1, 'products_sold' => 5, 'total_gross' => 100], + ]); + + $report = new OccurrenceSummaryReport($this->cache, $this->queryBuilder, $this->eventRepository); + $result = $report->generateReport(1); + + $this->assertCount(1, $result); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Domain/SelfService/SelfServiceResendEmailServiceTest.php b/backend/tests/Unit/Services/Domain/SelfService/SelfServiceResendEmailServiceTest.php index ef83d435a8..a083973ac3 100644 --- a/backend/tests/Unit/Services/Domain/SelfService/SelfServiceResendEmailServiceTest.php +++ b/backend/tests/Unit/Services/Domain/SelfService/SelfServiceResendEmailServiceTest.php @@ -25,11 +25,17 @@ class SelfServiceResendEmailServiceTest extends TestCase { private SelfServiceResendEmailService $service; + private MockInterface|SendAttendeeTicketService $sendAttendeeTicketService; + private MockInterface|SendOrderDetailsService $sendOrderDetailsService; + private MockInterface|AttendeeRepositoryInterface $attendeeRepository; + private MockInterface|OrderRepositoryInterface $orderRepository; + private MockInterface|EventRepositoryInterface $eventRepository; + private MockInterface|OrderAuditLogService $orderAuditLogService; protected function setUp(): void @@ -53,7 +59,7 @@ protected function setUp(): void ); } - public function testResendAttendeeTicketSuccessfully(): void + public function test_resend_attendee_ticket_successfully(): void { $attendeeId = 456; $orderId = 123; @@ -73,9 +79,12 @@ public function testResendAttendeeTicketSuccessfully(): void $event->shouldReceive('getEventSettings')->andReturn($eventSettings); $event->shouldReceive('getOrganizer')->andReturn($organizer); + // Three eager-loads: order (with nested order items), event_occurrence, + // and product — so the attendee-ticket email can render the occurrence + // date, venue, and ticket type. $this->attendeeRepository ->shouldReceive('loadRelation') - ->once() + ->times(3) ->with(Mockery::type(Relationship::class)) ->andReturnSelf(); @@ -134,7 +143,7 @@ public function testResendAttendeeTicketSuccessfully(): void $this->assertTrue(true); } - public function testResendOrderConfirmationSuccessfully(): void + public function test_resend_order_confirmation_successfully(): void { $orderId = 123; $eventId = 1; @@ -169,9 +178,12 @@ public function testResendOrderConfirmationSuccessfully(): void ]) ->andReturn($order); + // organizer + event_settings + event_occurrences — the occurrence load is + // needed so OrderSummary can render the correct date for multi-occurrence + // orders where the primary-occurrence resolver returns null. $this->eventRepository ->shouldReceive('loadRelation') - ->twice() + ->times(3) ->andReturnSelf(); $this->eventRepository @@ -213,7 +225,7 @@ public function testResendOrderConfirmationSuccessfully(): void $this->assertTrue(true); } - public function testResendAttendeeTicketLoadsCorrectRelationships(): void + public function test_resend_attendee_ticket_loads_correct_relationships(): void { $attendeeId = 456; $orderId = 123; @@ -229,14 +241,12 @@ public function testResendAttendeeTicketLoadsCorrectRelationships(): void $event->shouldReceive('getEventSettings')->andReturn($eventSettings); $event->shouldReceive('getOrganizer')->andReturn($organizer); + // Order + event_occurrence + product — three nested eager-loads so + // the resend email can show the occurrence date and the ticket type. $this->attendeeRepository ->shouldReceive('loadRelation') - ->once() - ->with(Mockery::on(function ($relationship) { - return $relationship instanceof Relationship - && $relationship->getDomainObject() === OrderDomainObject::class - && $relationship->getName() === 'order'; - })) + ->times(3) + ->with(Mockery::type(Relationship::class)) ->andReturnSelf(); $this->attendeeRepository @@ -283,7 +293,7 @@ public function testResendAttendeeTicketLoadsCorrectRelationships(): void $this->assertTrue(true); } - public function testResendOrderConfirmationLoadsCorrectRelationships(): void + public function test_resend_order_confirmation_loads_correct_relationships(): void { $orderId = 123; $eventId = 1; @@ -298,17 +308,22 @@ public function testResendOrderConfirmationLoadsCorrectRelationships(): void $event->shouldReceive('getEventSettings')->andReturn($eventSettings); $event->shouldReceive('getOrganizer')->andReturn($organizer); - $loadRelationCallCount = 0; $this->orderRepository ->shouldReceive('loadRelation') ->times(3) - ->with(Mockery::on(function ($domainObject) use (&$loadRelationCallCount) { - $loadRelationCallCount++; - return in_array($domainObject, [ - OrderItemDomainObject::class, + ->with(Mockery::on(function ($arg) { + // OrderItem is now passed as a Relationship with a nested + // event_occurrence load so the summary email can show the + // occurrence date. Attendee and Invoice are still plain class + // strings. + if ($arg instanceof \HiEvents\Repository\Eloquent\Value\Relationship) { + return true; + } + + return in_array($arg, [ AttendeeDomainObject::class, InvoiceDomainObject::class, - ]); + ], true); })) ->andReturnSelf(); @@ -319,7 +334,7 @@ public function testResendOrderConfirmationLoadsCorrectRelationships(): void $this->eventRepository ->shouldReceive('loadRelation') - ->twice() + ->times(3) ->andReturnSelf(); $this->eventRepository diff --git a/backend/tests/Unit/Services/Domain/Waitlist/CancelWaitlistEntryServiceTest.php b/backend/tests/Unit/Services/Domain/Waitlist/CancelWaitlistEntryServiceTest.php index 208249eb4c..146c1ed3b3 100644 --- a/backend/tests/Unit/Services/Domain/Waitlist/CancelWaitlistEntryServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Waitlist/CancelWaitlistEntryServiceTest.php @@ -119,6 +119,7 @@ public function testSuccessfullyCancelsByTokenWhenStatusIsOfferedDeletesOrder(): $entry->shouldReceive('getOrderId')->andReturn($orderId); $entry->shouldReceive('getEventId')->andReturn(10); $entry->shouldReceive('getProductPriceId')->andReturn(20); + $entry->shouldReceive('getEventOccurrenceId')->andReturn(30); $this->waitlistEntryRepository ->shouldReceive('findFirstWhere') @@ -161,7 +162,9 @@ public function testSuccessfullyCancelsByTokenWhenStatusIsOfferedDeletesOrder(): $this->assertEquals(WaitlistEntryStatus::CANCELLED->name, $result->getStatus()); Event::assertDispatched(CapacityChangedEvent::class, function ($event) { - return $event->eventId === 10 && $event->productId === 99; + return $event->eventId === 10 + && $event->productId === 99 + && $event->eventOccurrenceId === 30; }); } diff --git a/backend/tests/Unit/Services/Domain/Waitlist/CreateWaitlistEntryServiceTest.php b/backend/tests/Unit/Services/Domain/Waitlist/CreateWaitlistEntryServiceTest.php index 815481c98d..1c6a928a05 100644 --- a/backend/tests/Unit/Services/Domain/Waitlist/CreateWaitlistEntryServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Waitlist/CreateWaitlistEntryServiceTest.php @@ -70,18 +70,19 @@ public function testSuccessfullyCreatesWaitlistEntryWithCorrectPosition(): void 'event_id' => 1, ['status', 'in', [WaitlistEntryStatus::WAITING->name, WaitlistEntryStatus::OFFERED->name]], 'product_price_id' => 10, + 'event_occurrence_id' => null, ]) ->andReturnNull(); $this->waitlistEntryRepository ->shouldReceive('lockForProductPrice') ->once() - ->with(10); + ->with(10, null); $this->waitlistEntryRepository ->shouldReceive('getMaxPosition') ->once() - ->with(10) + ->with(10, null) ->andReturn(3); $createdEntry = new WaitlistEntryDomainObject(); @@ -100,6 +101,7 @@ public function testSuccessfullyCreatesWaitlistEntryWithCorrectPosition(): void ->with(Mockery::on(function ($attributes) { return $attributes['event_id'] === 1 && $attributes['product_price_id'] === 10 + && $attributes['event_occurrence_id'] === null && $attributes['email'] === 'test@example.com' && $attributes['first_name'] === 'John' && $attributes['last_name'] === 'Doe' @@ -139,7 +141,7 @@ public function testPreventsDuplicateEntryForSameEmailAndProduct(): void $this->waitlistEntryRepository ->shouldReceive('lockForProductPrice') ->once() - ->with(10); + ->with(10, null); $this->waitlistEntryRepository ->shouldReceive('findFirstWhere') @@ -149,6 +151,7 @@ public function testPreventsDuplicateEntryForSameEmailAndProduct(): void 'event_id' => 1, ['status', 'in', [WaitlistEntryStatus::WAITING->name, WaitlistEntryStatus::OFFERED->name]], 'product_price_id' => 10, + 'event_occurrence_id' => null, ]) ->andReturn($existingEntry); @@ -184,11 +187,12 @@ public function testDispatchesSendWaitlistConfirmationEmailJob(): void $this->waitlistEntryRepository ->shouldReceive('lockForProductPrice') ->once() - ->with(10); + ->with(10, null); $this->waitlistEntryRepository ->shouldReceive('getMaxPosition') ->once() + ->with(10, null) ->andReturn(0); $createdEntry = new WaitlistEntryDomainObject(); @@ -225,7 +229,7 @@ public function testPreventsDuplicateEntryWithPlusAlias(): void $this->waitlistEntryRepository ->shouldReceive('lockForProductPrice') ->once() - ->with(10); + ->with(10, null); $this->waitlistEntryRepository ->shouldReceive('findFirstWhere') @@ -235,6 +239,7 @@ public function testPreventsDuplicateEntryWithPlusAlias(): void 'event_id' => 1, ['status', 'in', [WaitlistEntryStatus::WAITING->name, WaitlistEntryStatus::OFFERED->name]], 'product_price_id' => 10, + 'event_occurrence_id' => null, ]) ->andReturn($existingEntry); diff --git a/backend/tests/Unit/Services/Domain/Waitlist/ProcessWaitlistServiceTest.php b/backend/tests/Unit/Services/Domain/Waitlist/ProcessWaitlistServiceTest.php index e307ebba3f..8921263eaf 100644 --- a/backend/tests/Unit/Services/Domain/Waitlist/ProcessWaitlistServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Waitlist/ProcessWaitlistServiceTest.php @@ -2,7 +2,9 @@ namespace Tests\Unit\Services\Domain\Waitlist; +use HiEvents\DomainObjects\Enums\EventType; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; @@ -14,9 +16,11 @@ use HiEvents\Exceptions\ResourceConflictException; use HiEvents\Exceptions\ResourceNotFoundException; use HiEvents\Jobs\Waitlist\SendWaitlistOfferEmailJob; +use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use HiEvents\Repository\Interfaces\WaitlistEntryRepositoryInterface; +use HiEvents\Services\Domain\EventOccurrence\OccurrencePurchaseEligibilityService; use HiEvents\Services\Domain\Order\OrderItemProcessingService; use HiEvents\Services\Domain\Order\OrderManagementService; use HiEvents\Services\Domain\Product\AvailableProductQuantitiesFetchService; @@ -33,14 +37,25 @@ class ProcessWaitlistServiceTest extends TestCase { private ProcessWaitlistService $service; + private MockInterface|WaitlistEntryRepositoryInterface $waitlistEntryRepository; + private MockInterface|DatabaseManager $databaseManager; + private MockInterface|OrderManagementService $orderManagementService; + private MockInterface|OrderItemProcessingService $orderItemProcessingService; + private MockInterface|ProductRepositoryInterface $productRepository; + private MockInterface|AvailableProductQuantitiesFetchService $availableQuantitiesService; + private MockInterface|ProductPriceRepositoryInterface $productPriceRepository; + private MockInterface|EventOccurrenceRepositoryInterface $eventOccurrenceRepository; + + private MockInterface|OccurrencePurchaseEligibilityService $eligibilityService; + protected function setUp(): void { parent::setUp(); @@ -52,6 +67,39 @@ protected function setUp(): void $this->productRepository = Mockery::mock(ProductRepositoryInterface::class); $this->availableQuantitiesService = Mockery::mock(AvailableProductQuantitiesFetchService::class); $this->productPriceRepository = Mockery::mock(ProductPriceRepositoryInterface::class); + $this->eventOccurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->eligibilityService = Mockery::mock(OccurrencePurchaseEligibilityService::class); + + // Default to "always eligible" for the existing happy-path tests; the + // dedicated past/cancelled/visibility tests override these on the spy. + // assertOccurrencePurchasable's return type is the loaded domain object, + // so we need a real instance — null would TypeError before the test + // assertion can run. + $defaultEligibilityOccurrence = new EventOccurrenceDomainObject; + $defaultEligibilityOccurrence->setId(50); + $defaultEligibilityOccurrence->setEventId(1); + $this->eligibilityService + ->shouldReceive('assertOccurrencePurchasable') + ->zeroOrMoreTimes() + ->andReturn($defaultEligibilityOccurrence) + ->byDefault(); + $this->eligibilityService + ->shouldReceive('assertProductsVisibleOnOccurrence') + ->zeroOrMoreTimes() + ->andReturnNull() + ->byDefault(); + + // The eligibility helper looks up the productPrice to feed the visibility + // check; tests that don't reach offerEntry don't bother re-mocking this + // path so we provide a benign default that resolves to a synthetic price. + $defaultProductPrice = new ProductPriceDomainObject; + $defaultProductPrice->setId(0); + $defaultProductPrice->setProductId(0); + $this->productPriceRepository + ->shouldReceive('findById') + ->zeroOrMoreTimes() + ->andReturn($defaultProductPrice) + ->byDefault(); $this->waitlistEntryRepository ->shouldReceive('lockForProductPrice') @@ -65,6 +113,16 @@ protected function setUp(): void ->zeroOrMoreTimes() ->andReturn(true); + $occurrence = new EventOccurrenceDomainObject; + $occurrence->setId(50); + $occurrence->setEventId(1); + + $this->eventOccurrenceRepository + ->shouldReceive('findWhere') + ->zeroOrMoreTimes() + ->andReturn(collect([$occurrence])) + ->byDefault(); + $this->service = new ProcessWaitlistService( waitlistEntryRepository: $this->waitlistEntryRepository, databaseManager: $this->databaseManager, @@ -73,29 +131,34 @@ protected function setUp(): void productRepository: $this->productRepository, availableQuantitiesService: $this->availableQuantitiesService, productPriceRepository: $this->productPriceRepository, + eventOccurrenceRepository: $this->eventOccurrenceRepository, + eligibilityService: $this->eligibilityService, ); } private function createMockEvent(int $id = 1, string $currency = 'USD'): EventDomainObject { - $event = new EventDomainObject(); + $event = new EventDomainObject; $event->setId($id); $event->setCurrency($currency); + $event->setType(EventType::SINGLE->name); + return $event; } private function createMockEventSettings(?int $timeoutMinutes = 30): EventSettingDomainObject { - $eventSettings = new EventSettingDomainObject(); + $eventSettings = new EventSettingDomainObject; $eventSettings->setWaitlistOfferTimeoutMinutes($timeoutMinutes); + return $eventSettings; } - private function mockAvailableQuantities(int $eventId, int $priceId, int $quantityAvailable = 10): void + private function mockAvailableQuantities(int $eventId, int $priceId, int $quantityAvailable = 10, ?int $occurrenceId = 50): void { $this->availableQuantitiesService ->shouldReceive('getAvailableProductQuantities') - ->with($eventId, true) + ->with($eventId, true, $occurrenceId) ->andReturn(new AvailableProductQuantitiesResponseDTO( productQuantities: collect([ new AvailableProductQuantitiesDTO( @@ -113,7 +176,7 @@ private function mockAvailableQuantities(int $eventId, int $priceId, int $quanti private function mockOrderCreation(): OrderDomainObject { - $order = new OrderDomainObject(); + $order = new OrderDomainObject; $order->setId(100); $order->setShortId('o_test123'); @@ -122,11 +185,12 @@ private function mockOrderCreation(): OrderDomainObject ->once() ->withArgs(function () { $args = func_get_args(); - return count($args) >= 7 && is_string($args[6]) && !empty($args[6]); + + return count($args) >= 7 && is_string($args[6]) && ! empty($args[6]); }) ->andReturn($order); - $productPrice = new ProductPriceDomainObject(); + $productPrice = new ProductPriceDomainObject; $productPrice->setId(1); $productPrice->setProductId(10); @@ -134,7 +198,7 @@ private function mockOrderCreation(): OrderDomainObject ->shouldReceive('findById') ->andReturn($productPrice); - $product = new ProductDomainObject(); + $product = new ProductDomainObject; $product->setId(10); $product->setProductPrices(new Collection([$productPrice])); @@ -145,7 +209,7 @@ private function mockOrderCreation(): OrderDomainObject ->shouldReceive('findById') ->andReturn($product); - $orderItem = new OrderItemDomainObject(); + $orderItem = new OrderItemDomainObject; $this->orderItemProcessingService ->shouldReceive('process') ->once() @@ -159,7 +223,7 @@ private function mockOrderCreation(): OrderDomainObject return $order; } - public function testSuccessfullyOffersToNextWaitingEntry(): void + public function test_successfully_offers_to_next_waiting_entry(): void { Bus::fake(); @@ -181,11 +245,12 @@ public function testSuccessfullyOffersToNextWaitingEntry(): void $waitingEntry->shouldReceive('getId')->andReturn(1); $waitingEntry->shouldReceive('getLocale')->andReturn('en'); $waitingEntry->shouldReceive('getProductPriceId')->andReturn($productPriceId); + $waitingEntry->shouldReceive('getEventOccurrenceId')->andReturn(null); $this->waitlistEntryRepository ->shouldReceive('getNextWaitingEntries') ->once() - ->with($productPriceId, Mockery::any()) + ->with($productPriceId) ->andReturn(new Collection([$waitingEntry])); $order = $this->mockOrderCreation(); @@ -196,7 +261,7 @@ public function testSuccessfullyOffersToNextWaitingEntry(): void ->with( Mockery::on(function ($attributes) use ($order) { return $attributes['status'] === WaitlistEntryStatus::OFFERED->name - && !empty($attributes['offer_token']) + && ! empty($attributes['offer_token']) && $attributes['offered_at'] !== null && $attributes['offer_expires_at'] !== null && $attributes['order_id'] === $order->getId(); @@ -204,7 +269,7 @@ public function testSuccessfullyOffersToNextWaitingEntry(): void ['id' => 1], ); - $updatedEntry = new WaitlistEntryDomainObject(); + $updatedEntry = new WaitlistEntryDomainObject; $updatedEntry->setId(1); $updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name); $updatedEntry->setOfferToken('some-token'); @@ -225,11 +290,12 @@ public function testSuccessfullyOffersToNextWaitingEntry(): void Bus::assertDispatched(SendWaitlistOfferEmailJob::class, function ($job) { $reflection = new \ReflectionClass($job); $sessionProp = $reflection->getProperty('sessionIdentifier'); - return !empty($sessionProp->getValue($job)); + + return ! empty($sessionProp->getValue($job)); }); } - public function testSetsCorrectOfferTokenAndOfferExpiresAt(): void + public function test_sets_correct_offer_token_and_offer_expires_at(): void { Bus::fake(); @@ -252,11 +318,12 @@ public function testSetsCorrectOfferTokenAndOfferExpiresAt(): void $waitingEntry->shouldReceive('getId')->andReturn(5); $waitingEntry->shouldReceive('getLocale')->andReturn('en'); $waitingEntry->shouldReceive('getProductPriceId')->andReturn($productPriceId); + $waitingEntry->shouldReceive('getEventOccurrenceId')->andReturn(null); $this->waitlistEntryRepository ->shouldReceive('getNextWaitingEntries') ->once() - ->with($productPriceId, Mockery::any()) + ->with($productPriceId) ->andReturn(new Collection([$waitingEntry])); $this->mockOrderCreation(); @@ -268,12 +335,13 @@ public function testSetsCorrectOfferTokenAndOfferExpiresAt(): void ->with( Mockery::on(function ($attributes) use (&$capturedAttributes) { $capturedAttributes = $attributes; + return true; }), ['id' => 5], ); - $updatedEntry = new WaitlistEntryDomainObject(); + $updatedEntry = new WaitlistEntryDomainObject; $updatedEntry->setId(5); $updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name); @@ -293,7 +361,7 @@ public function testSetsCorrectOfferTokenAndOfferExpiresAt(): void $this->assertNotNull($capturedAttributes['order_id']); } - public function testCreatesReservedOrderWhenOffering(): void + public function test_creates_reserved_order_when_offering(): void { Bus::fake(); @@ -315,14 +383,15 @@ public function testCreatesReservedOrderWhenOffering(): void $waitingEntry->shouldReceive('getId')->andReturn(1); $waitingEntry->shouldReceive('getLocale')->andReturn('en'); $waitingEntry->shouldReceive('getProductPriceId')->andReturn($productPriceId); + $waitingEntry->shouldReceive('getEventOccurrenceId')->andReturn(null); $this->waitlistEntryRepository ->shouldReceive('getNextWaitingEntries') ->once() - ->with($productPriceId, Mockery::any()) + ->with($productPriceId) ->andReturn(new Collection([$waitingEntry])); - $order = new OrderDomainObject(); + $order = new OrderDomainObject; $order->setId(100); $order->setShortId('o_test123'); @@ -330,17 +399,17 @@ public function testCreatesReservedOrderWhenOffering(): void ->shouldReceive('createNewOrder') ->once() ->with( - Mockery::on(fn($v) => $v === $event->getId()), - Mockery::on(fn($v) => $v instanceof EventDomainObject), - Mockery::on(fn($v) => $v === 30), - Mockery::on(fn($v) => $v === 'en'), - Mockery::on(fn($v) => $v === null), - Mockery::on(fn($v) => $v === null), - Mockery::on(fn($v) => is_string($v) && !empty($v)), + Mockery::on(fn ($v) => $v === $event->getId()), + Mockery::on(fn ($v) => $v instanceof EventDomainObject), + Mockery::on(fn ($v) => $v === 30), + Mockery::on(fn ($v) => $v === 'en'), + Mockery::on(fn ($v) => $v === null), + Mockery::on(fn ($v) => $v === null), + Mockery::on(fn ($v) => is_string($v) && ! empty($v)), ) ->andReturn($order); - $productPrice = new ProductPriceDomainObject(); + $productPrice = new ProductPriceDomainObject; $productPrice->setId(1); $productPrice->setProductId(10); @@ -348,7 +417,7 @@ public function testCreatesReservedOrderWhenOffering(): void ->shouldReceive('findById') ->andReturn($productPrice); - $product = new ProductDomainObject(); + $product = new ProductDomainObject; $product->setId(10); $product->setProductPrices(new Collection([$productPrice])); @@ -360,7 +429,7 @@ public function testCreatesReservedOrderWhenOffering(): void ->with(10) ->andReturn($product); - $orderItem = new OrderItemDomainObject(); + $orderItem = new OrderItemDomainObject; $this->orderItemProcessingService ->shouldReceive('process') ->once() @@ -375,11 +444,11 @@ public function testCreatesReservedOrderWhenOffering(): void ->shouldReceive('updateWhere') ->once() ->with( - Mockery::on(fn($attrs) => $attrs['order_id'] === 100), + Mockery::on(fn ($attrs) => $attrs['order_id'] === 100), ['id' => 1], ); - $updatedEntry = new WaitlistEntryDomainObject(); + $updatedEntry = new WaitlistEntryDomainObject; $updatedEntry->setId(1); $updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name); $updatedEntry->setOrderId(100); @@ -395,7 +464,109 @@ public function testCreatesReservedOrderWhenOffering(): void $this->assertEquals(100, $result->first()->getOrderId()); } - public function testThrowsWhenNoWaitingEntries(): void + public function test_single_event_waitlist_reserved_order_uses_hidden_occurrence(): void + { + Bus::fake(); + + $productPriceId = 10; + $occurrenceId = 321; + $event = $this->createMockEvent(id: 44); + $eventSettings = $this->createMockEventSettings(30); + + $occurrence = new EventOccurrenceDomainObject; + $occurrence->setId($occurrenceId); + $occurrence->setEventId($event->getId()); + + $this->eventOccurrenceRepository + ->shouldReceive('findWhere') + ->twice() + ->andReturn(collect([$occurrence])); + + $this->databaseManager + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(fn ($callback) => $callback()); + + $this->mockAvailableQuantities($event->getId(), $productPriceId, occurrenceId: $occurrenceId); + + $waitingEntry = Mockery::mock(WaitlistEntryDomainObject::class); + $waitingEntry->shouldReceive('getId')->andReturn(1); + $waitingEntry->shouldReceive('getLocale')->andReturn('en'); + $waitingEntry->shouldReceive('getProductPriceId')->andReturn($productPriceId); + $waitingEntry->shouldReceive('getEventOccurrenceId')->andReturn(null); + + $this->waitlistEntryRepository + ->shouldReceive('getNextWaitingEntries') + ->once() + ->with($productPriceId) + ->andReturn(new Collection([$waitingEntry])); + + $order = new OrderDomainObject; + $order->setId(100); + $order->setShortId('o_test123'); + + $this->orderManagementService + ->shouldReceive('createNewOrder') + ->once() + ->andReturn($order); + + $productPrice = new ProductPriceDomainObject; + $productPrice->setId($productPriceId); + $productPrice->setProductId(10); + + $this->productPriceRepository + ->shouldReceive('findById') + ->andReturn($productPrice); + + $product = new ProductDomainObject; + $product->setId(10); + $product->setProductPrices(new Collection([$productPrice])); + + $this->productRepository + ->shouldReceive('loadRelation') + ->andReturnSelf(); + $this->productRepository + ->shouldReceive('findById') + ->with(10) + ->andReturn($product); + + $capturedOccurrenceId = null; + $orderItem = new OrderItemDomainObject; + $this->orderItemProcessingService + ->shouldReceive('process') + ->once() + ->withArgs(function ($orderArg, Collection $productsOrderDetails) use ($order, $occurrenceId, &$capturedOccurrenceId) { + $capturedOccurrenceId = $productsOrderDetails->first()->event_occurrence_id; + + return $orderArg === $order && $capturedOccurrenceId === $occurrenceId; + }) + ->andReturn(new Collection([$orderItem])); + + $this->orderManagementService + ->shouldReceive('updateOrderTotals') + ->once() + ->andReturn($order); + + $this->waitlistEntryRepository + ->shouldReceive('updateWhere') + ->once(); + + $updatedEntry = new WaitlistEntryDomainObject; + $updatedEntry->setId(1); + $updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name); + $updatedEntry->setOrderId(100); + + $this->waitlistEntryRepository + ->shouldReceive('findById') + ->once() + ->andReturn($updatedEntry); + + $this->service->offerToNext($productPriceId, 1, $event, $eventSettings); + + $this->assertSame($occurrenceId, $capturedOccurrenceId); + } + + public function test_throws_when_no_waiting_entries(): void { $productPriceId = 10; $quantity = 2; @@ -409,20 +580,18 @@ public function testThrowsWhenNoWaitingEntries(): void return $callback(); }); - $this->mockAvailableQuantities($event->getId(), $productPriceId); - $this->waitlistEntryRepository ->shouldReceive('getNextWaitingEntries') ->once() - ->with($productPriceId, Mockery::any()) - ->andReturn(new Collection()); + ->with($productPriceId) + ->andReturn(new Collection); $this->expectException(NoCapacityAvailableException::class); $this->service->offerToNext($productPriceId, $quantity, $event, $eventSettings); } - public function testCapsOffersAtAvailableCapacity(): void + public function test_caps_offers_at_available_capacity(): void { Bus::fake(); @@ -444,11 +613,12 @@ public function testCapsOffersAtAvailableCapacity(): void $waitingEntry->shouldReceive('getId')->andReturn(1); $waitingEntry->shouldReceive('getLocale')->andReturn('en'); $waitingEntry->shouldReceive('getProductPriceId')->andReturn($productPriceId); + $waitingEntry->shouldReceive('getEventOccurrenceId')->andReturn(null); $this->waitlistEntryRepository ->shouldReceive('getNextWaitingEntries') ->once() - ->with($productPriceId, Mockery::any()) + ->with($productPriceId) ->andReturn(new Collection([$waitingEntry])); $this->mockOrderCreation(); @@ -457,7 +627,7 @@ public function testCapsOffersAtAvailableCapacity(): void ->shouldReceive('updateWhere') ->once(); - $updatedEntry = new WaitlistEntryDomainObject(); + $updatedEntry = new WaitlistEntryDomainObject; $updatedEntry->setId(1); $updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name); @@ -471,7 +641,101 @@ public function testCapsOffersAtAvailableCapacity(): void $this->assertCount(1, $result); } - public function testThrowsWhenNoCapacityAtAll(): void + public function test_offer_to_next_skips_full_occurrence_and_offers_later_eligible_entry(): void + { + Bus::fake(); + + $productPriceId = 10; + $event = $this->createMockEvent(); + $event->setType(EventType::RECURRING->name); + $eventSettings = $this->createMockEventSettings(); + + $this->databaseManager + ->shouldReceive('transaction') + ->once() + ->andReturnUsing(fn ($callback) => $callback()); + + $fullOccurrenceEntry = new WaitlistEntryDomainObject; + $fullOccurrenceEntry->setId(1); + $fullOccurrenceEntry->setLocale('en'); + $fullOccurrenceEntry->setProductPriceId($productPriceId); + $fullOccurrenceEntry->setEventOccurrenceId(11); + + $eligibleEntry = new WaitlistEntryDomainObject; + $eligibleEntry->setId(2); + $eligibleEntry->setLocale('en'); + $eligibleEntry->setProductPriceId($productPriceId); + $eligibleEntry->setEventOccurrenceId(22); + + $this->waitlistEntryRepository + ->shouldReceive('getNextWaitingEntries') + ->once() + ->with($productPriceId) + ->andReturn(new Collection([$fullOccurrenceEntry, $eligibleEntry])); + + $this->mockAvailableQuantities($event->getId(), $productPriceId, 0, 11); + $this->mockAvailableQuantities($event->getId(), $productPriceId, 1, 22); + + $order = new OrderDomainObject; + $order->setId(100); + $order->setShortId('o_test123'); + + $this->orderManagementService + ->shouldReceive('createNewOrder') + ->once() + ->andReturn($order); + + $productPrice = new ProductPriceDomainObject; + $productPrice->setId($productPriceId); + $productPrice->setProductId(10); + + $this->productPriceRepository + ->shouldReceive('findById') + ->andReturn($productPrice); + + $product = new ProductDomainObject; + $product->setId(10); + $product->setProductPrices(new Collection([$productPrice])); + + $this->productRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->productRepository->shouldReceive('findById')->andReturn($product); + + $this->orderItemProcessingService + ->shouldReceive('process') + ->once() + ->withArgs(function ($orderArg, Collection $productsOrderDetails) use ($order) { + return $orderArg === $order + && $productsOrderDetails->first()->event_occurrence_id === 22; + }) + ->andReturn(new Collection([new OrderItemDomainObject])); + + $this->orderManagementService + ->shouldReceive('updateOrderTotals') + ->once() + ->andReturn($order); + + $this->waitlistEntryRepository + ->shouldReceive('updateWhere') + ->once() + ->with(Mockery::any(), ['id' => 2]); + + $updatedEntry = new WaitlistEntryDomainObject; + $updatedEntry->setId(2); + $updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name); + + $this->waitlistEntryRepository + ->shouldReceive('findById') + ->once() + ->with(2) + ->andReturn($updatedEntry); + + $result = $this->service->offerToNext($productPriceId, 1, $event, $eventSettings); + + $this->assertCount(1, $result); + $this->assertSame(2, $result->first()->getId()); + } + + public function test_throws_when_no_capacity_at_all(): void { $productPriceId = 10; $quantity = 2; @@ -487,12 +751,22 @@ public function testThrowsWhenNoCapacityAtAll(): void $this->mockAvailableQuantities($event->getId(), $productPriceId, 0); + $waitingEntry = Mockery::mock(WaitlistEntryDomainObject::class); + $waitingEntry->shouldReceive('getProductPriceId')->andReturn($productPriceId); + $waitingEntry->shouldReceive('getEventOccurrenceId')->andReturn(null); + + $this->waitlistEntryRepository + ->shouldReceive('getNextWaitingEntries') + ->once() + ->with($productPriceId) + ->andReturn(new Collection([$waitingEntry])); + $this->expectException(NoCapacityAvailableException::class); $this->service->offerToNext($productPriceId, $quantity, $event, $eventSettings); } - public function testOfferExpiresAtUsesDefaultWhenTimeoutNotSet(): void + public function test_offer_expires_at_uses_default_when_timeout_not_set(): void { Bus::fake(); @@ -514,11 +788,12 @@ public function testOfferExpiresAtUsesDefaultWhenTimeoutNotSet(): void $waitingEntry->shouldReceive('getId')->andReturn(1); $waitingEntry->shouldReceive('getLocale')->andReturn('en'); $waitingEntry->shouldReceive('getProductPriceId')->andReturn($productPriceId); + $waitingEntry->shouldReceive('getEventOccurrenceId')->andReturn(null); $this->waitlistEntryRepository ->shouldReceive('getNextWaitingEntries') ->once() - ->with($productPriceId, Mockery::any()) + ->with($productPriceId) ->andReturn(new Collection([$waitingEntry])); $this->mockOrderCreation(); @@ -530,12 +805,13 @@ public function testOfferExpiresAtUsesDefaultWhenTimeoutNotSet(): void ->with( Mockery::on(function ($attributes) use (&$capturedAttributes) { $capturedAttributes = $attributes; + return true; }), ['id' => 1], ); - $updatedEntry = new WaitlistEntryDomainObject(); + $updatedEntry = new WaitlistEntryDomainObject; $updatedEntry->setId(1); $updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name); @@ -549,7 +825,7 @@ public function testOfferExpiresAtUsesDefaultWhenTimeoutNotSet(): void $this->assertNotNull($capturedAttributes['offer_expires_at']); } - public function testOfferSpecificEntrySuccessfully(): void + public function test_offer_specific_entry_successfully(): void { Bus::fake(); @@ -566,7 +842,7 @@ public function testOfferSpecificEntrySuccessfully(): void return $callback(); }); - $entry = new WaitlistEntryDomainObject(); + $entry = new WaitlistEntryDomainObject; $entry->setId($entryId); $entry->setStatus(WaitlistEntryStatus::WAITING->name); $entry->setLocale('en'); @@ -588,14 +864,14 @@ public function testOfferSpecificEntrySuccessfully(): void ->with( Mockery::on(function ($attributes) use ($order) { return $attributes['status'] === WaitlistEntryStatus::OFFERED->name - && !empty($attributes['offer_token']) + && ! empty($attributes['offer_token']) && $attributes['offered_at'] !== null && $attributes['order_id'] === $order->getId(); }), ['id' => $entryId], ); - $updatedEntry = new WaitlistEntryDomainObject(); + $updatedEntry = new WaitlistEntryDomainObject; $updatedEntry->setId($entryId); $updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name); $updatedEntry->setOrderId($order->getId()); @@ -614,7 +890,7 @@ public function testOfferSpecificEntrySuccessfully(): void Bus::assertDispatched(SendWaitlistOfferEmailJob::class); } - public function testOfferSpecificEntryThrowsWhenEntryNotFound(): void + public function test_offer_specific_entry_throws_when_entry_not_found(): void { $entryId = 99; $eventId = 1; @@ -639,7 +915,7 @@ public function testOfferSpecificEntryThrowsWhenEntryNotFound(): void $this->service->offerSpecificEntry($entryId, $eventId, $event, $eventSettings); } - public function testOfferSpecificEntryThrowsWhenStatusNotOfferable(): void + public function test_offer_specific_entry_throws_when_status_not_offerable(): void { $entryId = 7; $eventId = 1; @@ -653,7 +929,7 @@ public function testOfferSpecificEntryThrowsWhenStatusNotOfferable(): void return $callback(); }); - $entry = new WaitlistEntryDomainObject(); + $entry = new WaitlistEntryDomainObject; $entry->setId($entryId); $entry->setStatus(WaitlistEntryStatus::PURCHASED->name); @@ -667,7 +943,7 @@ public function testOfferSpecificEntryThrowsWhenStatusNotOfferable(): void $this->service->offerSpecificEntry($entryId, $eventId, $event, $eventSettings); } - public function testOfferSpecificEntryAllowsReOfferForExpiredEntries(): void + public function test_offer_specific_entry_allows_re_offer_for_expired_entries(): void { Bus::fake(); @@ -684,7 +960,7 @@ public function testOfferSpecificEntryAllowsReOfferForExpiredEntries(): void return $callback(); }); - $entry = new WaitlistEntryDomainObject(); + $entry = new WaitlistEntryDomainObject; $entry->setId($entryId); $entry->setStatus(WaitlistEntryStatus::OFFER_EXPIRED->name); $entry->setLocale('en'); @@ -703,7 +979,7 @@ public function testOfferSpecificEntryAllowsReOfferForExpiredEntries(): void ->shouldReceive('updateWhere') ->once(); - $updatedEntry = new WaitlistEntryDomainObject(); + $updatedEntry = new WaitlistEntryDomainObject; $updatedEntry->setId($entryId); $updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name); @@ -718,7 +994,7 @@ public function testOfferSpecificEntryAllowsReOfferForExpiredEntries(): void Bus::assertDispatched(SendWaitlistOfferEmailJob::class); } - public function testOfferSpecificEntryThrowsWhenNoCapacityAvailable(): void + public function test_offer_specific_entry_throws_when_no_capacity_available(): void { $entryId = 7; $eventId = 1; @@ -733,7 +1009,7 @@ public function testOfferSpecificEntryThrowsWhenNoCapacityAvailable(): void return $callback(); }); - $entry = new WaitlistEntryDomainObject(); + $entry = new WaitlistEntryDomainObject; $entry->setId($entryId); $entry->setStatus(WaitlistEntryStatus::WAITING->name); $entry->setLocale('en'); @@ -752,7 +1028,7 @@ public function testOfferSpecificEntryThrowsWhenNoCapacityAvailable(): void $this->service->offerSpecificEntry($entryId, $eventId, $event, $eventSettings); } - public function testOfferSpecificEntryThrowsWhenCapacityFullyOffered(): void + public function test_offer_specific_entry_throws_when_capacity_fully_offered(): void { $entryId = 7; $eventId = 1; @@ -767,7 +1043,7 @@ public function testOfferSpecificEntryThrowsWhenCapacityFullyOffered(): void return $callback(); }); - $entry = new WaitlistEntryDomainObject(); + $entry = new WaitlistEntryDomainObject; $entry->setId($entryId); $entry->setStatus(WaitlistEntryStatus::WAITING->name); $entry->setLocale('en'); diff --git a/docker/development/docker-compose.dev.yml b/docker/development/docker-compose.dev.yml index 1c605156cd..ba5683104d 100644 --- a/docker/development/docker-compose.dev.yml +++ b/docker/development/docker-compose.dev.yml @@ -109,8 +109,11 @@ services: POSTGRES_DB: '${DB_DATABASE}' POSTGRES_USER: '${DB_USERNAME}' POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}' + TEST_DB_NAME: '${TEST_DB_NAME:-hievents_test}' volumes: - 'app-pgsql:/var/lib/postgresql/data' + # Init scripts run once on a fresh data volume — creates hievents_test. + - './pgsql-init:/docker-entrypoint-initdb.d:ro' networks: - app healthcheck: diff --git a/docker/development/pgsql-init/01-create-test-db.sh b/docker/development/pgsql-init/01-create-test-db.sh new file mode 100755 index 0000000000..0fa09e4cdd --- /dev/null +++ b/docker/development/pgsql-init/01-create-test-db.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Postgres entrypoint init script — runs once on a fresh data volume. +# Creates the hievents_test database used by the test suite (the BaseRepositoryTest +# guard refuses to run against any database whose name does not end in _test). +# +# Idempotent: existing test DBs are left alone. + +set -euo pipefail + +TEST_DB="${TEST_DB_NAME:-hievents_test}" + +psql -v ON_ERROR_STOP=1 --username "${POSTGRES_USER}" --dbname "${POSTGRES_DB}" <<-EOSQL + SELECT 'CREATE DATABASE ${TEST_DB} OWNER ${POSTGRES_USER}' + WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${TEST_DB}')\gexec +EOSQL + +echo "Test database '${TEST_DB}' is ready." diff --git a/docker/development/start-dev.sh b/docker/development/start-dev.sh index 599ae442f8..53a3289a07 100755 --- a/docker/development/start-dev.sh +++ b/docker/development/start-dev.sh @@ -5,36 +5,95 @@ CERTS_FLAG="$1" RED='\033[0;31m' GREEN='\033[0;32m' -BG_BLACK='\033[40m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +MAGENTA='\033[0;35m' +BOLD='\033[1m' +DIM='\033[2m' NC='\033[0m' # No Color CERTS_DIR="./certs" -echo -e "${GREEN}${BG_BLACK}Installing Hi.Events...${NC}" +print_banner() { + echo "" + echo -e "${CYAN}${BOLD} ╔═══════════════════════════════════════════╗${NC}" + echo -e "${CYAN}${BOLD} ║ ║${NC}" + echo -e "${CYAN}${BOLD} ║ ${MAGENTA}Hi.Events Dev Launcher${CYAN} ║${NC}" + echo -e "${CYAN}${BOLD} ║ ║${NC}" + echo -e "${CYAN}${BOLD} ╚═══════════════════════════════════════════╝${NC}" + echo "" +} + +step() { + echo -e "${BLUE}${BOLD}▶${NC} ${BOLD}$1${NC}" +} + +info() { + echo -e " ${DIM}$1${NC}" +} + +ok() { + echo -e " ${GREEN}✓${NC} $1" +} + +warn() { + echo -e " ${YELLOW}⚠${NC} $1" +} + +fail() { + echo -e " ${RED}✗${NC} $1" +} + +# Prompt yes/no. $1 = question, $2 = default ("y" or "n") +ask_yes_no() { + local prompt="$1" + local default="$2" + local hint + if [ "$default" = "y" ]; then + hint="${BOLD}Y${NC}/n" + else + hint="y/${BOLD}N${NC}" + fi + while true; do + echo -ne "${YELLOW}?${NC} ${BOLD}$prompt${NC} [$hint] " + read -r reply + reply="${reply:-$default}" + case "$reply" in + [Yy]*) return 0 ;; + [Nn]*) return 1 ;; + *) echo -e " ${DIM}Please answer y or n.${NC}" ;; + esac + done +} + +print_banner mkdir -p "$CERTS_DIR" generate_unsigned_certs() { if [ ! -f "$CERTS_DIR/localhost.crt" ] || [ ! -f "$CERTS_DIR/localhost.key" ]; then - echo -e "${GREEN}Generating unsigned SSL certificates...${NC}" - openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout "$CERTS_DIR/localhost.key" -out "$CERTS_DIR/localhost.crt" -subj "/CN=localhost" + step "Generating unsigned SSL certificates" + openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout "$CERTS_DIR/localhost.key" -out "$CERTS_DIR/localhost.crt" -subj "/CN=localhost" > /dev/null 2>&1 + ok "Certificates generated" else - echo -e "${GREEN}SSL certificates already exist, skipping generation...${NC}" + ok "SSL certificates already exist" fi } generate_signed_certs() { if [ ! -f "$CERTS_DIR/localhost.crt" ] || [ ! -f "$CERTS_DIR/localhost.key" ]; then if ! command -v mkcert &> /dev/null; then - echo -e "${RED}mkcert is not installed.${NC}" - echo "Please install mkcert by following the instructions at: https://github.com/FiloSottile/mkcert#installation" - echo "Alternatively, you can generate unsigned certificates by using '--certs=unsigned' or omitting the --certs flag." + fail "mkcert is not installed." + info "Install via https://github.com/FiloSottile/mkcert#installation" + info "Or use unsigned certs: '--certs=unsigned' (or omit --certs)" exit 1 else - echo -e "${GREEN}Generating signed SSL certificates with mkcert...${NC}" - mkcert -key-file "$CERTS_DIR/localhost.key" -cert-file "$CERTS_DIR/localhost.crt" localhost 127.0.0.1 ::1 + step "Generating signed SSL certificates with mkcert" + mkcert -key-file "$CERTS_DIR/localhost.key" -cert-file "$CERTS_DIR/localhost.crt" localhost 127.0.0.1 ::1 > /dev/null 2>&1 + ok "Certificates generated" fi else - echo -e "${GREEN}SSL certificates already exist, skipping generation...${NC}" + ok "SSL certificates already exist" fi } @@ -47,33 +106,72 @@ case "$CERTS_FLAG" in ;; esac -$COMPOSE_CMD up -d +echo "" +step "Setup options" -if [ $? -ne 0 ]; then - echo -e "${RED}Failed to start services with docker-compose.${NC}" - exit 1 +WIPE_DB=false +if ask_yes_no "Wipe the database and start fresh?" "n"; then + WIPE_DB=true + warn "Database will be wiped on startup" +else + info "Keeping existing database" fi -echo -e "${GREEN}Running composer install in the backend service...${NC}" +REINSTALL_DEPS=true +if ask_yes_no "Reinstall frontend dependencies (yarn install)?" "y"; then + REINSTALL_DEPS=true + info "Frontend image will be rebuilt with fresh deps" +else + REINSTALL_DEPS=false + info "Skipping frontend dependency reinstall" +fi + +echo "" + +if [ "$WIPE_DB" = true ]; then + step "Tearing down existing containers and volumes" + $COMPOSE_CMD down -v > /dev/null 2>&1 + ok "Containers and volumes removed" +elif [ "$REINSTALL_DEPS" = true ]; then + step "Removing frontend container to refresh node_modules" + $COMPOSE_CMD rm -sfv frontend > /dev/null 2>&1 + ok "Frontend container removed" +fi + +if [ "$REINSTALL_DEPS" = true ]; then + step "Rebuilding frontend image (running yarn install)" + if ! $COMPOSE_CMD build frontend; then + fail "Frontend image build failed" + exit 1 + fi + ok "Frontend image rebuilt" +fi + +step "Starting services" +if ! $COMPOSE_CMD up -d; then + fail "Failed to start services with docker compose." + exit 1 +fi +ok "Services started" -$COMPOSE_CMD exec -T backend composer install \ +step "Running composer install in the backend service" +if ! $COMPOSE_CMD exec -T backend composer install \ --ignore-platform-reqs \ --no-interaction \ --optimize-autoloader \ - --prefer-dist - -if [ $? -ne 0 ]; then - echo -e "${RED}Composer install failed within the backend service.${NC}" + --prefer-dist; then + fail "Composer install failed within the backend service." exit 1 fi +ok "Composer dependencies installed" -echo -e "${GREEN}Waiting for the database to be ready...${NC}" -while ! $COMPOSE_CMD logs pgsql | grep "ready to accept connections" > /dev/null; do - echo -n '.' - sleep 1 +step "Waiting for the database to be ready" +while ! $COMPOSE_CMD logs pgsql 2>/dev/null | grep "ready to accept connections" > /dev/null; do + echo -n '.' + sleep 1 done - -echo -e "\n${GREEN}Database is ready. Proceeding with migrations...${NC}" +echo "" +ok "Database is ready" if [ ! -f ./../../backend/.env ]; then $COMPOSE_CMD exec backend cp .env.example .env @@ -83,17 +181,40 @@ if [ ! -f ./../../frontend/.env ]; then $COMPOSE_CMD exec frontend cp .env.example .env fi +step "Running migrations and setup" $COMPOSE_CMD exec backend php artisan key:generate $COMPOSE_CMD exec backend php artisan migrate $COMPOSE_CMD exec backend chmod -R 775 /var/www/html/vendor/ezyang/htmlpurifier/library/HTMLPurifier/DefinitionCache/Serializer $COMPOSE_CMD exec backend php artisan storage:link if [ $? -ne 0 ]; then - echo -e "${RED}Migrations failed.${NC}" + fail "Migrations failed." exit 1 fi +ok "Migrations complete" + +echo "" +step "Background workers" + +if ask_yes_no "Start the queue worker?" "y"; then + $COMPOSE_CMD exec -d backend php artisan queue:work --queue=default,webhook-queue --sleep=3 --tries=3 --timeout=60 + ok "Queue worker started (detached)" +else + info "Skipped queue worker — start it later with:" + info "$COMPOSE_CMD exec backend php artisan queue:work" +fi + +if ask_yes_no "Start the scheduler?" "y"; then + $COMPOSE_CMD exec -d backend php artisan schedule:work + ok "Scheduler started (detached)" +else + info "Skipped scheduler — start it later with:" + info "$COMPOSE_CMD exec backend php artisan schedule:work" +fi -echo -e "${GREEN}Hi.Events is now running at:${NC} https://localhost:8443" +echo "" +echo -e "${GREEN}${BOLD} 🎉 Hi.Events is now running at:${NC} ${CYAN}${BOLD}https://localhost:8443${NC}" +echo "" case "$(uname -s)" in Darwin) open https://localhost:8443/auth/register ;; diff --git a/frontend/public/blank-slate/occurrence-schedule.svg b/frontend/public/blank-slate/occurrence-schedule.svg new file mode 100644 index 0000000000..6283ffd08e --- /dev/null +++ b/frontend/public/blank-slate/occurrence-schedule.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/logos/logos.zip b/frontend/public/logos/logos.zip new file mode 100644 index 0000000000..cec03f9d72 Binary files /dev/null and b/frontend/public/logos/logos.zip differ diff --git a/frontend/src/api/account.client.ts b/frontend/src/api/account.client.ts index 848c398612..31e4288e65 100644 --- a/frontend/src/api/account.client.ts +++ b/frontend/src/api/account.client.ts @@ -1,5 +1,5 @@ import {api} from "./client.ts"; -import {Account, GenericDataResponse, IdParam, User, StripeConnectAccountsResponse} from "../types.ts"; +import {Account, GenericDataResponse, User} from "../types.ts"; interface CreateAccountRequest { first_name: string; @@ -21,14 +21,4 @@ export const accountClient = { const response = await api.put>('accounts', account); return response.data; }, - getStripeConnectDetails: async (accountId: IdParam, platform?: string) => { - const response = await api.post>(`accounts/${accountId}/stripe/connect`, { - platform - }); - return response.data; - }, - getStripeConnectAccounts: async (accountId: IdParam) => { - const response = await api.get>(`accounts/${accountId}/stripe/connect_accounts`); - return response.data; - } -} \ No newline at end of file +} diff --git a/frontend/src/api/admin.client.ts b/frontend/src/api/admin.client.ts index 4fde19eac9..2bff604899 100644 --- a/frontend/src/api/admin.client.ts +++ b/frontend/src/api/admin.client.ts @@ -76,39 +76,49 @@ export interface UpdateConfigurationData { bypass_application_fees?: boolean; } -export interface AssignConfigurationData { - configuration_id: number; -} - -export interface AccountVatSetting { +export interface AdminOrganizerVatSetting { id: number; - account_id: number; vat_registered: boolean; vat_number: string | null; vat_validated: boolean; + vat_validation_status: 'PENDING' | 'VALIDATING' | 'VALID' | 'INVALID' | 'FAILED' | null; vat_validation_date: string | null; business_name: string | null; business_address: string | null; vat_country_code: string | null; - created_at: string; - updated_at: string; +} + +export interface AdminOrganizerSummary { + id: IdParam; + name: string; + configuration: AccountConfiguration | null; + vat_setting: AdminOrganizerVatSetting | null; } export interface AdminAccountDetail extends AdminAccount { - configuration?: AccountConfiguration; - vat_setting?: AccountVatSetting; messaging_tier?: AccountMessagingTier; + organizers: AdminOrganizerSummary[]; } - -export interface UpdateAccountVatSettingsData { - vat_registered: boolean; +export interface UpdateAdminOrganizerVatSettingData { + vat_registered?: boolean; vat_number?: string | null; + vat_validated?: boolean; business_name?: string | null; business_address?: string | null; vat_country_code?: string | null; } +export interface UpdateOrganizerConfigurationOverrideData { + application_fees?: { + fixed: number; + percentage: number; + currency: string; + }; + bypass_application_fees?: boolean; +} + + export interface AdminStats { total_users: number; total_accounts: number; @@ -453,14 +463,6 @@ export const adminClient = { return response.data; }, - assignConfiguration: async (accountId: IdParam, data: AssignConfigurationData) => { - const response = await api.put( - `admin/accounts/${accountId}/configuration`, - data - ); - return response.data; - }, - getAllConfigurations: async () => { const response = await api.get>( 'admin/configurations' @@ -489,10 +491,26 @@ export const adminClient = { return response.data; }, - updateAccountVatSettings: async (accountId: IdParam, data: UpdateAccountVatSettingsData) => { - const response = await api.put>( - `admin/accounts/${accountId}/vat-settings`, - data + assignOrganizerConfiguration: async (organizerId: IdParam, configurationId: IdParam) => { + const response = await api.put( + `admin/organizers/${organizerId}/configuration`, + { configuration_id: configurationId }, + ); + return response.data; + }, + + updateOrganizerConfigurationOverride: async (organizerId: IdParam, data: UpdateOrganizerConfigurationOverrideData) => { + const response = await api.patch>( + `admin/organizers/${organizerId}/configuration`, + data, + ); + return response.data; + }, + + updateOrganizerVatSetting: async (organizerId: IdParam, data: UpdateAdminOrganizerVatSettingData) => { + const response = await api.put>( + `admin/organizers/${organizerId}/vat-settings`, + data, ); return response.data; }, diff --git a/frontend/src/api/attendee.client.ts b/frontend/src/api/attendee.client.ts index ba44fb839d..258526dd4e 100644 --- a/frontend/src/api/attendee.client.ts +++ b/frontend/src/api/attendee.client.ts @@ -19,6 +19,8 @@ export interface CreateAttendeeRequest extends EditAttendeeRequest { send_confirmation_email: boolean, taxes_and_fees: TaxAndFee[], locale: SupportedLocales, + event_occurrence_id?: number | null, + override_capacity?: boolean, } export const attendeesClient = { @@ -56,8 +58,9 @@ export const attendeesClient = { }); return response.data; }, - export: async (eventId: IdParam): Promise => { - const response = await api.post(`events/${eventId}/attendees/export`, {}, { + export: async (eventId: IdParam, eventOccurrenceId?: number | null): Promise => { + const body = eventOccurrenceId ? {event_occurrence_id: eventOccurrenceId} : {}; + const response = await api.post(`events/${eventId}/attendees/export`, body, { responseType: 'blob', }); diff --git a/frontend/src/api/check-in.client.ts b/frontend/src/api/check-in.client.ts index 709121e915..a74b55c66b 100644 --- a/frontend/src/api/check-in.client.ts +++ b/frontend/src/api/check-in.client.ts @@ -1,7 +1,9 @@ import {publicApi} from "./public-client"; import { Attendee, + AttendeeDetailPublic, CheckInList, + CheckInListStats, GenericDataResponse, GenericPaginatedResponse, IdParam, PublicCheckIn, @@ -14,6 +16,11 @@ export const publicCheckInClient = { const response = await publicApi.get>(`/check-in-lists/${checkInListShortId}`); return response.data; }, + getCheckInListStats: async (checkInListShortId: IdParam, eventOccurrenceId?: number | null) => { + const qs = eventOccurrenceId ? `?event_occurrence_id=${eventOccurrenceId}` : ''; + const response = await publicApi.get>(`/check-in-lists/${checkInListShortId}/stats${qs}`); + return response.data; + }, getCheckInListAttendees: async (checkInListShortId: IdParam, pagination: QueryFilters) => { const response = await publicApi.get>(`/check-in-lists/${checkInListShortId}/attendees` + queryParamsHelper.buildQueryString(pagination)); return response.data; @@ -22,6 +29,10 @@ export const publicCheckInClient = { const response = await publicApi.get>(`/check-in-lists/${checkInListShortId}/attendees/${attendeePublicId}`); return response.data; }, + getCheckInListAttendeeDetail: async (checkInListShortId: IdParam, attendeePublicId: IdParam) => { + const response = await publicApi.get>(`/check-in-lists/${checkInListShortId}/attendees/${attendeePublicId}/detail`); + return response.data; + }, createCheckIn: async (checkInListShortId: IdParam, attendeePublicId: IdParam, action: 'check-in' | 'check-in-and-mark-order-as-paid') => { const response = await publicApi.post>(`/check-in-lists/${checkInListShortId}/check-ins`, { "attendees": [ diff --git a/frontend/src/api/event-occurrence.client.ts b/frontend/src/api/event-occurrence.client.ts new file mode 100644 index 0000000000..3ea3704312 --- /dev/null +++ b/frontend/src/api/event-occurrence.client.ts @@ -0,0 +1,135 @@ +import {api} from "./client"; +import {publicApi} from "./public-client"; +import { + BulkUpdateOccurrencesRequest, + EventOccurrence, + GenerateOccurrencesRequest, + GenericDataResponse, + GenericPaginatedResponse, + IdParam, + ProductOccurrenceVisibility, + ProductPriceOccurrenceOverride, + QueryFilters, + UpsertEventOccurrenceRequest, + UpsertPriceOverrideRequest, +} from "../types"; +import {queryParamsHelper} from "../utilites/queryParamsHelper.ts"; + +export const eventOccurrenceClient = { + all: async (eventId: IdParam, pagination: QueryFilters, options: {includeStats?: boolean} = {}) => { + const queryString = queryParamsHelper.buildQueryString(pagination); + const separator = queryString.includes('?') ? '&' : '?'; + const url = `events/${eventId}/occurrences` + queryString + + (options.includeStats === false ? `${separator}include_stats=false` : ''); + const response = await api.get>(url); + return response.data; + }, + + get: async (eventId: IdParam, occurrenceId: IdParam) => { + const response = await api.get>( + `events/${eventId}/occurrences/${occurrenceId}` + ); + return response.data; + }, + + create: async (eventId: IdParam, data: UpsertEventOccurrenceRequest) => { + const response = await api.post>( + `events/${eventId}/occurrences`, + data + ); + return response.data; + }, + + update: async (eventId: IdParam, occurrenceId: IdParam, data: UpsertEventOccurrenceRequest) => { + const response = await api.put>( + `events/${eventId}/occurrences/${occurrenceId}`, + data + ); + return response.data; + }, + + delete: async (eventId: IdParam, occurrenceId: IdParam) => { + const response = await api.delete>( + `events/${eventId}/occurrences/${occurrenceId}` + ); + return response.data; + }, + + cancel: async (eventId: IdParam, occurrenceId: IdParam, refundOrders: boolean = false) => { + const response = await api.post>( + `events/${eventId}/occurrences/${occurrenceId}/cancel`, + {refund_orders: refundOrders} + ); + return response.data; + }, + + reactivate: async (eventId: IdParam, occurrenceId: IdParam) => { + const response = await api.post>( + `events/${eventId}/occurrences/${occurrenceId}/reactivate`, + {} + ); + return response.data; + }, + + generate: async (eventId: IdParam, data: GenerateOccurrencesRequest) => { + const response = await api.post>( + `events/${eventId}/occurrences/generate`, + data + ); + return response.data; + }, + + bulkUpdate: async (eventId: IdParam, data: BulkUpdateOccurrencesRequest) => { + const response = await api.post<{ updated_count: number; updated_ids: number[] }>( + `events/${eventId}/occurrences/bulk-update`, + data + ); + return response.data; + }, + + getPriceOverrides: async (eventId: IdParam, occurrenceId: IdParam) => { + const response = await api.get>( + `events/${eventId}/occurrences/${occurrenceId}/price-overrides` + ); + return response.data; + }, + + upsertPriceOverride: async (eventId: IdParam, occurrenceId: IdParam, data: UpsertPriceOverrideRequest) => { + const response = await api.put>( + `events/${eventId}/occurrences/${occurrenceId}/price-overrides`, + data + ); + return response.data; + }, + + deletePriceOverride: async (eventId: IdParam, occurrenceId: IdParam, overrideId: IdParam) => { + const response = await api.delete>( + `events/${eventId}/occurrences/${occurrenceId}/price-overrides/${overrideId}` + ); + return response.data; + }, + + getProductVisibility: async (eventId: IdParam, occurrenceId: IdParam) => { + const response = await api.get>( + `events/${eventId}/occurrences/${occurrenceId}/product-visibility` + ); + return response.data; + }, + + updateProductVisibility: async (eventId: IdParam, occurrenceId: IdParam, productIds: IdParam[]) => { + const response = await api.put>( + `events/${eventId}/occurrences/${occurrenceId}/product-visibility`, + {product_ids: productIds} + ); + return response.data; + }, +}; + +export const eventOccurrenceClientPublic = { + all: async (eventId: IdParam, pagination: QueryFilters) => { + const response = await publicApi.get>( + `events/${eventId}/occurrences` + queryParamsHelper.buildQueryString(pagination) + ); + return response.data; + }, +}; diff --git a/frontend/src/api/event.client.ts b/frontend/src/api/event.client.ts index ccf18834e9..c440647a75 100644 --- a/frontend/src/api/event.client.ts +++ b/frontend/src/api/event.client.ts @@ -37,9 +37,12 @@ export const eventsClient = { return response.data; }, - getEventStats: async (eventId: IdParam, dateRange?: string) => { - const params = dateRange ? `?date_range=${dateRange}` : ''; - const response = await api.get>('events/' + eventId + '/stats' + params); + getEventStats: async (eventId: IdParam, options: {occurrenceId?: IdParam; dateRange?: string} = {}) => { + const params = new URLSearchParams(); + if (options.occurrenceId) params.set('occurrence_id', String(options.occurrenceId)); + if (options.dateRange) params.set('date_range', options.dateRange); + const qs = params.toString(); + const response = await api.get>(`events/${eventId}/stats${qs ? '?' + qs : ''}`); return response.data; }, @@ -92,8 +95,12 @@ export const eventsClient = { return response.data; }, - getEventReport: async (eventId: IdParam, reportType: IdParam, startDate?: string, endDate?: string) => { - const response = await api.get>('events/' + eventId + '/reports/' + reportType + '?start_date=' + startDate + '&end_date=' + endDate); + getEventReport: async (eventId: IdParam, reportType: IdParam, startDate?: string, endDate?: string, occurrenceId?: IdParam) => { + const params = new URLSearchParams(); + if (startDate) params.append('start_date', startDate); + if (endDate) params.append('end_date', endDate); + if (occurrenceId) params.append('occurrence_id', String(occurrenceId)); + const response = await api.get>('events/' + eventId + '/reports/' + reportType + '?' + params.toString()); return response.data; } } @@ -104,8 +111,12 @@ export const eventsClientPublic = { return response.data; }, - findByID: async (eventId: any, promoCode: null | string) => { - const response = await publicApi.get>('events/' + eventId + (promoCode ? '?promo_code=' + promoCode : '')); + findByID: async (eventId: any, promoCode?: null | string, eventOccurrenceId?: number | null) => { + const params = new URLSearchParams(); + if (promoCode) params.set('promo_code', promoCode); + if (eventOccurrenceId) params.set('event_occurrence_id', String(eventOccurrenceId)); + const queryString = params.toString(); + const response = await publicApi.get>('events/' + eventId + (queryString ? '?' + queryString : '')); return response.data; }, } diff --git a/frontend/src/api/order.client.ts b/frontend/src/api/order.client.ts index c54d8e5a06..d56d575a95 100644 --- a/frontend/src/api/order.client.ts +++ b/frontend/src/api/order.client.ts @@ -41,6 +41,7 @@ export interface ProductPriceQuantityFormValue { export interface ProductFormValue { product_id: number, quantities: ProductPriceQuantityFormValue[], + event_occurrence_id?: number, } export interface ProductFormPayload { @@ -86,8 +87,9 @@ export const orderClient = { return response.data; }, - exportOrders: async (eventId: IdParam): Promise => { - const response = await api.post(`events/${eventId}/orders/export`, {}, { + exportOrders: async (eventId: IdParam, eventOccurrenceId?: number | null): Promise => { + const body = eventOccurrenceId ? {event_occurrence_id: eventOccurrenceId} : {}; + const response = await api.post(`events/${eventId}/orders/export`, body, { responseType: 'blob', }); diff --git a/frontend/src/api/organizer-stripe.client.ts b/frontend/src/api/organizer-stripe.client.ts new file mode 100644 index 0000000000..e7d12d3ff9 --- /dev/null +++ b/frontend/src/api/organizer-stripe.client.ts @@ -0,0 +1,30 @@ +import {api} from "./client.ts"; +import { + GenericDataResponse, + IdParam, + OrganizerStripeConnectAccountsResponse, + OrganizerStripeConnectDetails, +} from "../types.ts"; + +export const organizerStripeClient = { + createOrGetConnectDetails: async (organizerId: IdParam, platform?: string) => { + const response = await api.post>( + `organizers/${organizerId}/stripe/connect`, + {platform}, + ); + return response.data; + }, + getConnectAccounts: async (organizerId: IdParam) => { + const response = await api.get>( + `organizers/${organizerId}/stripe/connect_accounts`, + ); + return response.data; + }, + copyConnection: async (organizerId: IdParam, sourceOrganizerId: IdParam) => { + const response = await api.post>( + `organizers/${organizerId}/stripe/copy_from/${sourceOrganizerId}`, + {}, + ); + return response.data; + }, +}; diff --git a/frontend/src/api/vat.client.ts b/frontend/src/api/vat.client.ts index 68068b4de2..63dcb0c563 100644 --- a/frontend/src/api/vat.client.ts +++ b/frontend/src/api/vat.client.ts @@ -3,9 +3,9 @@ import {GenericDataResponse, IdParam} from "../types.ts"; export type VatValidationStatus = 'PENDING' | 'VALIDATING' | 'VALID' | 'INVALID' | 'FAILED'; -export interface AccountVatSetting { +export interface VatSetting { id: number; - account_id: number; + organizer_id: number; vat_registered: boolean; vat_number: string | null; vat_validated: boolean; @@ -26,17 +26,17 @@ export interface UpsertVatSettingRequest { } export const vatClient = { - getVatSetting: async (accountId: IdParam) => { - const response = await api.get>( - `accounts/${accountId}/vat-settings` + getVatSetting: async (organizerId: IdParam) => { + const response = await api.get>( + `organizers/${organizerId}/vat-settings`, ); return response.data; }, - upsertVatSetting: async (accountId: IdParam, data: UpsertVatSettingRequest) => { - const response = await api.post>( - `accounts/${accountId}/vat-settings`, - data + upsertVatSetting: async (organizerId: IdParam, data: UpsertVatSettingRequest) => { + const response = await api.post>( + `organizers/${organizerId}/vat-settings`, + data, ); return response.data; }, diff --git a/frontend/src/api/waitlist.client.ts b/frontend/src/api/waitlist.client.ts index 96e6c05123..9fbda9f4ba 100644 --- a/frontend/src/api/waitlist.client.ts +++ b/frontend/src/api/waitlist.client.ts @@ -19,17 +19,24 @@ export const waitlistClient = { return response.data; }, - stats: async (eventId: IdParam) => { + stats: async (eventId: IdParam, eventOccurrenceId?: IdParam | null) => { + const query = new URLSearchParams(); + + if (eventOccurrenceId) { + query.set('event_occurrence_id', String(eventOccurrenceId)); + } + + const queryString = query.toString(); const response = await api.get( - `events/${eventId}/waitlist/stats`, + `events/${eventId}/waitlist/stats${queryString ? `?${queryString}` : ''}`, ); return response.data; }, - offerNext: async (eventId: IdParam, productPriceId: number, quantity: number = 1) => { + offerNext: async (eventId: IdParam, productPriceId: number, quantity: number = 1, eventOccurrenceId?: IdParam | null) => { const response = await api.post>( `events/${eventId}/waitlist/offer-next`, - {product_price_id: productPriceId, quantity}, + {product_price_id: productPriceId, quantity, event_occurrence_id: eventOccurrenceId}, ); return response.data; }, diff --git a/frontend/src/components/common/AddToCalendarCTA/index.tsx b/frontend/src/components/common/AddToCalendarCTA/index.tsx index 0ba32735dd..d47ea4000f 100644 --- a/frontend/src/components/common/AddToCalendarCTA/index.tsx +++ b/frontend/src/components/common/AddToCalendarCTA/index.tsx @@ -2,14 +2,16 @@ import {t} from "@lingui/macro"; import {Button} from "@mantine/core"; import {IconCalendar} from "@tabler/icons-react"; import {Event} from "../../../types.ts"; +import {OccurrenceDateOverride} from "../../../utilites/calendar.ts"; import {CalendarOptionsPopover} from "../CalendarOptionsPopover"; import classes from './AddToCalendarCTA.module.scss'; interface AddToCalendarCTAProps { event: Event; + occurrence?: OccurrenceDateOverride; } -export const AddToCalendarCTA = ({event}: AddToCalendarCTAProps) => { +export const AddToCalendarCTA = ({event, occurrence}: AddToCalendarCTAProps) => { return (
@@ -19,7 +21,7 @@ export const AddToCalendarCTA = ({event}: AddToCalendarCTAProps) => { {t`Don't forget!`} {t`Add this event to your calendar`}
- + diff --git a/frontend/src/components/common/AttendeeCheckInTable/PermissionDeniedMessage.tsx b/frontend/src/components/common/AttendeeCheckInTable/PermissionDeniedMessage.tsx deleted file mode 100644 index 8a005d19af..0000000000 --- a/frontend/src/components/common/AttendeeCheckInTable/PermissionDeniedMessage.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import {Anchor, Button} from "@mantine/core"; -import {t, Trans} from "@lingui/macro"; -import classes from "./QrScanner.module.scss"; - -interface PermissionDeniedMessageProps { - onRequestPermission: () => void; - onClose: () => void; -} - -export const PermissionDeniedMessage = ({ - onRequestPermission, - onClose -}: PermissionDeniedMessageProps) => { - return ( -
- - Camera permission was denied. Request - Permission again, - or if this doesn't work, - you will need to grant - this page access to your camera in your browser settings. - - -
- -
-
- ); -}; \ No newline at end of file diff --git a/frontend/src/components/common/AttendeeCheckInTable/QrScanner.module.scss b/frontend/src/components/common/AttendeeCheckInTable/QrScanner.module.scss deleted file mode 100644 index 33878f6a84..0000000000 --- a/frontend/src/components/common/AttendeeCheckInTable/QrScanner.module.scss +++ /dev/null @@ -1,100 +0,0 @@ -@use "../../../styles/mixins"; - -@keyframes colorfulBorder { - 0% { - border-color: #ffffff50; - } - 50% { - border-color: #00000050; - } - 100% { - border-color: #ffffff50; - } -} - -.videoContainer { - position: relative; - display: flex; - justify-content: center; - align-items: center; - - .permissionMessage { - position: absolute; - width: 100vw; - padding: 20px; - text-align: center; - background-color: #000000; - color: #fff; - z-index: 3; - - a { - color: #dddddd; - text-decoration: underline; - } - } - - .flashToggle { - position: absolute; - top: 20px; - left: 20px; - z-index: 2; - } - - .soundToggle { - position: absolute; - bottom: 20px; - left: 20px; - z-index: 2; - } - - .closeButton { - position: absolute; - top: 20px; - right: 20px; - z-index: 2; - } - - .switchCameraButton { - position: absolute; - bottom: 20px; - right: 20px; - z-index: 2; - } - - //scanner overlay is a square div that scales as the browser window scales - .scannerOverlay { - width: 60vw; - height: 60vw; - border: 5px solid #ffffff50; - position: absolute; - animation: colorfulBorder 10s infinite; - border-radius: 10px; - outline: solid 50vmax rgb(71 46 120 / 50%); - transition: outline-color 0.2s ease-out; - min-width: 200px; - min-height: 200px; - - @include mixins.respond-above(md) { - width: 40vw; - height: 40vw; - } - } - - .scannerOverlay.success { - outline: solid 50vmax rgb(80 148 80 / 75%); - } - - .scannerOverlay.failure { - outline: solid 50vmax rgb(193 72 72 / 75%); - } - - .scannerOverlay.checkingIn { - outline: solid 50vmax rgb(172 158 85 / 60%); - } - - video { - width: 100vw !important; - height: 100vh !important; - object-fit: cover; - } -} diff --git a/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx b/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx deleted file mode 100644 index 121fde9e31..0000000000 --- a/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import {useEffect, useRef, useState} from 'react'; -import QrScanner from 'qr-scanner'; -import {useDebouncedValue} from '@mantine/hooks'; -import classes from './QrScanner.module.scss'; -import {showError} from "../../../utilites/notifications.tsx"; -import {t} from "@lingui/macro"; -import {QrScannerControls} from './QrScannerControls'; -import {PermissionDeniedMessage} from './PermissionDeniedMessage'; - -interface QRScannerComponentProps { - onAttendeeScanned: (attendeePublicId: string) => void; - onClose: () => void; - isSoundOn?: boolean; -} - -export const QRScannerComponent = (props: QRScannerComponentProps) => { - const videoRef = useRef(null); - const qrScannerRef = useRef(null); - const [permissionGranted, setPermissionGranted] = useState(false); - const [permissionDenied, setPermissionDenied] = useState(false); - const [isCheckingIn, setIsCheckingIn] = useState(false); - const [isFlashAvailable, setIsFlashAvailable] = useState(false); - const [isFlashOn, setIsFlashOn] = useState(false); - const [cameraList, setCameraList] = useState(); - const [processedAttendeeIds, setProcessedAttendeeIds] = useState([]); - const latestProcessedAttendeeIdsRef = useRef([]); - - const [currentAttendeeId, setCurrentAttendeeId] = useState(null); - const [debouncedAttendeeId] = useDebouncedValue(currentAttendeeId, 1000); - const [isScanFailed, setIsScanFailed] = useState(false); - const [isScanSucceeded, setIsScanSucceeded] = useState(false); - - const scanSuccessAudioRef = useRef(null); - const scanErrorAudioRef = useRef(null); - const scanInProgressAudioRef = useRef(null); - - const [isSoundOn, setIsSoundOn] = useState(() => { - // Use the prop value if provided, otherwise fallback to unified storage - if (props.isSoundOn !== undefined) { - return props.isSoundOn; - } - const storedIsSoundOn = localStorage.getItem("scannerSoundOn"); - return storedIsSoundOn === null ? true : JSON.parse(storedIsSoundOn); - }); - - // Sync with prop changes - useEffect(() => { - if (props.isSoundOn !== undefined) { - setIsSoundOn(props.isSoundOn); - } - }, [props.isSoundOn]); - - useEffect(() => { - // Only save to localStorage if not controlled by props - if (props.isSoundOn === undefined) { - localStorage.setItem("scannerSoundOn", JSON.stringify(isSoundOn)); - } - }, [isSoundOn, props.isSoundOn]); - - useEffect(() => { - latestProcessedAttendeeIdsRef.current = processedAttendeeIds; - }, [processedAttendeeIds]); - - const startScanner = async () => { - try { - await navigator.mediaDevices.getUserMedia({video: true}); - setPermissionGranted(true); - if (videoRef.current) { - qrScannerRef.current = new QrScanner(videoRef.current, (result) => { - setCurrentAttendeeId(result.data); - }, { - maxScansPerSecond: 1, - }); - qrScannerRef.current.start(); - } - } catch (error) { - setPermissionDenied(true); - console.error(error); - } - }; - - useEffect(() => { - if (debouncedAttendeeId) { - const latestProcessedAttendeeIds = latestProcessedAttendeeIdsRef.current; - const alreadyScanned = latestProcessedAttendeeIds.includes(debouncedAttendeeId); - - if (isScanSucceeded || isScanFailed) { - return; - } - - if (alreadyScanned) { - showError(t`You already scanned this ticket`); - - setIsScanFailed(true); - setInterval(() => setIsScanFailed(false), 500); - if (isSoundOn && scanErrorAudioRef.current) { - scanErrorAudioRef.current.play(); - } - - return; - } - - if (!isCheckingIn && !alreadyScanned) { - setIsCheckingIn(true); - if (isSoundOn && scanInProgressAudioRef.current) { - scanInProgressAudioRef.current.play(); - } - - props.onAttendeeScanned(debouncedAttendeeId); - setIsCheckingIn(false); - setProcessedAttendeeIds(prevIds => [...prevIds, debouncedAttendeeId]); - setCurrentAttendeeId(null); - - setIsScanSucceeded(true); - setInterval(() => setIsScanSucceeded(false), 500); - if (isSoundOn && scanSuccessAudioRef.current) { - scanSuccessAudioRef.current.play(); - } - } - } - }, [debouncedAttendeeId]); - - const stopScanner = () => { - if (qrScannerRef.current) { - qrScannerRef.current.stop(); - qrScannerRef.current.destroy(); - qrScannerRef.current = null; - } - }; - - const handleClose = () => { - stopScanner(); - props.onClose(); - }; - - const handleFlashToggle = () => { - if (!isFlashAvailable) { - showError(t`Flash is not available on this device`); - return; - } - if (qrScannerRef.current) { - if (isFlashOn) { - qrScannerRef.current.turnFlashOff(); - } else { - qrScannerRef.current.turnFlashOn(); - } - setIsFlashOn(!isFlashOn); - } - }; - - const handleSoundToggle = () => { - setIsSoundOn(!isSoundOn); - }; - - const requestPermission = async () => { - setPermissionDenied(false); - await startScanner(); - }; - - const updateFlashAvailability = async () => { - if (qrScannerRef.current) { - const hasFlash = await qrScannerRef.current.hasFlash(); - setIsFlashAvailable(hasFlash); - } - }; - - useEffect(() => { - startScanner().then(() => { - updateFlashAvailability().catch(console.error); - QrScanner.listCameras(true) - .then(cameras => setCameraList(cameras)); - }); - - return () => { - if (permissionGranted) { - stopScanner(); - } - }; - }, []); - - const handleCameraSelection = (camera: QrScanner.Camera) => { - return qrScannerRef.current?.setCamera(camera.id) - .then(() => updateFlashAvailability().catch(console.error)); - }; - - return ( -
- {permissionDenied && ( - - )} - - - - - -
+ } + size="md" + centered + radius="md" + padding={24} + > +
+ {description} +
+ + + ); +}; diff --git a/frontend/src/components/common/CheckIn/CheckInInfoModal.tsx b/frontend/src/components/common/CheckIn/CheckInInfoModal.tsx index a0d5584f2b..a4cfe7aff4 100644 --- a/frontend/src/components/common/CheckIn/CheckInInfoModal.tsx +++ b/frontend/src/components/common/CheckIn/CheckInInfoModal.tsx @@ -1,8 +1,9 @@ import {Modal, Progress} from "@mantine/core"; -import {Trans} from "@lingui/macro"; +import {t, Trans} from "@lingui/macro"; import Truncate from "../Truncate"; import {CheckInList} from "../../../types.ts"; -import classes from "../../layouts/CheckIn/CheckIn.module.scss"; +import {PoweredByFooter} from "../PoweredByFooter"; +import {getConfig} from "../../../utilites/config"; interface CheckInInfoModalProps { isOpen: boolean; @@ -11,53 +12,97 @@ interface CheckInInfoModalProps { } export const CheckInInfoModal = ({ - isOpen, - checkInList, - onClose -}: CheckInInfoModalProps) => { + isOpen, + checkInList, + onClose, + }: CheckInInfoModalProps) => { if (!checkInList) return null; - + + const total = checkInList.total_attendees; + const checkedIn = checkInList.checked_in_attendees; + const percent = total > 0 ? (checkedIn / total) * 100 : 0; + const appName = getConfig("VITE_APP_NAME", "Hi.Events"); + const logoSrc = getConfig("VITE_APP_LOGO_LIGHT", "/logos/hi-events-text-light.svg"); + return ( - } + size="md" + centered + radius="md" + padding={24} + transitionProps={{transition: "fade", duration: 200}} > - - - - - - - - -
-
- <> -

- - {`${checkInList.checked_in_attendees}/${checkInList.total_attendees}`} checked in - -

+
+
+ {t`Progress`} +
+
+ {`${checkedIn} / ${total}`} checked in +
+ +
- - + {checkInList.description && ( +
+
+ {t`Staff instructions`}
- - {checkInList.description && ( -
- {checkInList.description} -
- )} + {checkInList.description}
- - + )} + +
+ {t`${appName} + +
+ ); }; diff --git a/frontend/src/components/common/CheckIn/HidScannerStatus.tsx b/frontend/src/components/common/CheckIn/HidScannerStatus.tsx deleted file mode 100644 index 4cc250f7be..0000000000 --- a/frontend/src/components/common/CheckIn/HidScannerStatus.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import {Button} from "@mantine/core"; -import {IconScan, IconX} from "@tabler/icons-react"; -import {t} from "@lingui/macro"; -import {showSuccess} from "../../../utilites/notifications.tsx"; - -interface HidScannerStatusProps { - isActive: boolean; - pageHasFocus: boolean; - onDisable: () => void; -} - -export const HidScannerStatus = ({ - isActive, - pageHasFocus, - onDisable -}: HidScannerStatusProps) => { - if (!isActive) return null; - - return ( -
-
- - - {pageHasFocus - ? 'USB Scanner Active - Ready to Scan' - : 'USB Scanner Paused - Click anywhere to resume scanning'} - -
- -
- ); -}; diff --git a/frontend/src/components/common/CheckIn/ScannerSelectionModal.tsx b/frontend/src/components/common/CheckIn/ScannerSelectionModal.tsx deleted file mode 100644 index 3ffe4c65eb..0000000000 --- a/frontend/src/components/common/CheckIn/ScannerSelectionModal.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import {Button, Modal, Stack} from "@mantine/core"; -import {IconCamera, IconScan} from "@tabler/icons-react"; -import {t} from "@lingui/macro"; -import {showSuccess} from "../../../utilites/notifications.tsx"; - -interface ScannerSelectionModalProps { - isOpen: boolean; - isHidScannerActive: boolean; - onClose: () => void; - onCameraSelect: () => void; - onHidScannerSelect: () => void; -} - -export const ScannerSelectionModal = ({ - isOpen, - isHidScannerActive, - onClose, - onCameraSelect, - onHidScannerSelect -}: ScannerSelectionModalProps) => { - return ( - - - - - - - - ); -}; \ No newline at end of file diff --git a/frontend/src/components/common/CheckInListTable/CheckInListTable.module.scss b/frontend/src/components/common/CheckInListTable/CheckInListTable.module.scss new file mode 100644 index 0000000000..7ae5c700bd --- /dev/null +++ b/frontend/src/components/common/CheckInListTable/CheckInListTable.module.scss @@ -0,0 +1,102 @@ +.listDetails { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + flex: 1; +} + +.listName { + font-size: 15px; + font-weight: 600; + line-height: 1.3; + text-decoration: none; + color: var(--mantine-color-text); + + &:hover { + text-decoration: underline; + cursor: pointer; + } +} + +.nameRow { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.listDescription { + font-size: 12px; + color: var(--mantine-color-dimmed); + line-height: 1.3; +} + +.productsText { + font-size: 12px; + color: var(--mantine-color-dimmed); + line-height: 1.3; +} + +.occurrenceContainer { + display: flex; + flex-direction: column; + gap: 4px; +} + +.occurrenceChip { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + line-height: 1.3; + color: var(--mantine-primary-color-filled); + white-space: nowrap; +} + +.occurrenceText { + font-size: 13px; + color: var(--mantine-color-dimmed); +} + +.progressContainer { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 140px; +} + +.progressText { + font-size: 12px; + color: var(--mantine-color-dimmed); + display: flex; + align-items: center; + gap: 4px; +} + +.statusBadge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + + &[data-status="active"] { + background: var(--mantine-color-green-1); + color: var(--mantine-color-green-9); + } + + &[data-status="inactive"] { + background: var(--mantine-color-gray-1); + color: var(--mantine-color-gray-6); + } +} + +.actionsMenu { + display: flex; + align-items: center; + justify-content: flex-end; +} diff --git a/frontend/src/components/common/CheckInListTable/index.tsx b/frontend/src/components/common/CheckInListTable/index.tsx new file mode 100644 index 0000000000..039103157f --- /dev/null +++ b/frontend/src/components/common/CheckInListTable/index.tsx @@ -0,0 +1,300 @@ +import {Anchor, Badge, Button, Progress} from '@mantine/core'; +import {CheckInList, Event, EventType, IdParam} from "../../../types.ts"; +import { + IconCalendarEvent, + IconCheck, + IconCopy, + IconExternalLink, + IconPencil, + IconPlus, + IconTrash, + IconUsers, + IconX, +} from "@tabler/icons-react"; +import {useMemo, useState} from "react"; +import {useDisclosure} from "@mantine/hooks"; +import {useParams} from "react-router"; +import {t, Trans} from "@lingui/macro"; +import {NoResultsSplash} from "../NoResultsSplash"; +import {EditCheckInListModal} from "../../modals/EditCheckInListModal"; +import {useDeleteCheckInList} from "../../../mutations/useDeleteCheckInList"; +import {showError, showSuccess} from "../../../utilites/notifications.tsx"; +import {confirmationDialog} from "../../../utilites/confirmationDialog.tsx"; +import {TanStackTable, TanStackTableColumn} from "../TanStackTable"; +import {ActionMenu} from '../ActionMenu'; +import {CellContext} from "@tanstack/react-table"; +import Truncate from "../Truncate"; +import {formatDateWithLocale} from "../../../utilites/dates.ts"; +import classes from './CheckInListTable.module.scss'; + +interface CheckInListTableProps { + checkInLists: CheckInList[]; + openCreateModal: () => void; + event?: Event; +} + +export const CheckInListTable = ({checkInLists, openCreateModal, event}: CheckInListTableProps) => { + const [editModalOpen, {open: openEditModal, close: closeEditModal}] = useDisclosure(false); + const [selectedCheckInListId, setSelectedCheckInListId] = useState(); + const deleteMutation = useDeleteCheckInList(); + const {eventId} = useParams(); + const isRecurring = event?.type === EventType.RECURRING; + + const handleDeleteCheckInList = (checkInListId: IdParam, eventId: IdParam) => { + deleteMutation.mutate({checkInListId, eventId}, { + onSuccess: () => { + showSuccess(t`Check-In List deleted successfully`); + }, + onError: (error: any) => { + showError(error.message); + } + }); + } + + const columns = useMemo[]>( + () => { + const allColumns: TanStackTableColumn[] = [ + { + id: 'name', + header: t`Check-In List`, + enableHiding: false, + cell: (info: CellContext) => { + const list = info.row.original; + const coversAllTickets = !list.products || list.products.length === 0; + return ( +
+
+ { + setSelectedCheckInListId(list.id as IdParam); + openEditModal(); + }} + > + + + {list.is_system_default && ( + {t`Default`} + )} +
+
+ {coversAllTickets + ? t`Covers every ticket` + : list.products!.length === 1 + ? t`Includes 1 product` + : Includes {list.products!.length} products + } +
+
+ ); + }, + meta: { + headerStyle: {minWidth: 250}, + }, + }, + { + id: 'occurrence', + header: t`Date`, + enableHiding: true, + cell: (info: CellContext) => { + const list = info.row.original; + const occurrence = list.event_occurrence; + + if (!occurrence || !event?.timezone) { + return ( +
+ {t`All Dates`} +
+ ); + } + + return ( +
+ + + {formatDateWithLocale(occurrence.start_date, 'shortDate', event.timezone)} + {' '} + {formatDateWithLocale(occurrence.start_date, 'timeOnly', event.timezone)} + {occurrence.label && ` · ${occurrence.label}`} + +
+ ); + }, + meta: { + headerStyle: {minWidth: 160}, + }, + }, + { + id: 'progress', + header: t`Check-Ins`, + enableHiding: true, + cell: (info: CellContext) => { + const list = info.row.original; + const percentage = list.total_attendees === 0 + ? 0 + : (list.checked_in_attendees / list.total_attendees) * 100; + return ( +
+ 0 ? 'primary' : 'green'} + size="md" + /> +
+ + {list.checked_in_attendees} / {list.total_attendees} +
+
+ ); + }, + meta: { + headerStyle: {minWidth: 160}, + }, + }, + { + id: 'status', + header: t`Status`, + enableHiding: true, + cell: (info: CellContext) => { + const list = info.row.original; + const isActive = !list.is_expired && list.is_active; + return ( +
+ {isActive ? ( + <> + + {t`Active`} + + ) : ( + <> + + {t`Inactive`} + + )} +
+ ); + }, + meta: { + headerStyle: {minWidth: 100}, + }, + }, + { + id: 'actions', + header: '', + enableHiding: false, + cell: (info: CellContext) => { + const list = info.row.original; + const manageItems = [ + { + label: t`Edit Check-In List`, + icon: , + onClick: () => { + setSelectedCheckInListId(list.id as IdParam); + openEditModal(); + } + }, + { + label: t`Copy Check-In URL`, + icon: , + onClick: () => { + navigator.clipboard.writeText( + `${window.location.origin}/check-in/${list.short_id}` + ).then(() => { + showSuccess(t`Check-In URL copied to clipboard`); + }); + } + }, + { + label: t`Open Check-In Page`, + icon: , + onClick: () => { + window.open(`/check-in/${list.short_id}`, '_blank'); + } + }, + ]; + const groups: {label: string; items: any[]}[] = [ + {label: t`Manage`, items: manageItems}, + ]; + if (!list.is_system_default) { + groups.push({ + label: t`Danger zone`, + items: [ + { + label: t`Delete Check-In List`, + icon: , + onClick: () => { + confirmationDialog( + t`Are you sure you would like to delete this Check-In List?`, + () => { + handleDeleteCheckInList( + list.id as IdParam, + eventId, + ); + }) + }, + color: 'red', + }, + ], + }); + } + return ( +
+ +
+ ); + }, + meta: { + sticky: 'right', + }, + }, + ]; + + return allColumns.filter(column => { + if (column.id === 'occurrence' && !isRecurring) { + return false; + } + return true; + }); + }, + [eventId, isRecurring, event?.timezone] + ); + + if (checkInLists.length === 0) { + return ( + +

+ +

+ Check-in lists help you manage event entry by day, area, or ticket type. You can link tickets to specific lists such as VIP zones or Day 1 passes and share a secure check-in link with staff. No account is required. Check-in works on mobile, desktop, or tablet, using a device camera or HID USB scanner.

+ +

+ + + )} + /> + ); + } + + return ( + <> + + {(editModalOpen && selectedCheckInListId) + && } + + ); +}; diff --git a/frontend/src/components/common/CheckInStatusModal/index.tsx b/frontend/src/components/common/CheckInStatusModal/index.tsx index 17f3825555..8147d53c1d 100644 --- a/frontend/src/components/common/CheckInStatusModal/index.tsx +++ b/frontend/src/components/common/CheckInStatusModal/index.tsx @@ -22,7 +22,7 @@ export const CheckInStatusModal = ({ isOpen, onClose }: CheckInStatusModalProps) => { - const {data: checkInListsResponse, isLoading, ...rest} = useGetEventCheckInLists(eventId); + const {data: checkInListsResponse, isLoading} = useGetEventCheckInLists(eventId); if (isLoading) { return ( @@ -48,11 +48,17 @@ export const CheckInStatusModal = ({ ); } - const checkInLists = checkInListsResponse?.data || []; + const allCheckInLists = checkInListsResponse?.data || []; + const checkInLists = allCheckInLists.filter(list => + !list.event_occurrence_id || list.event_occurrence_id === attendee.event_occurrence_id + ); const attendeeCheckIns = attendee.check_ins || []; const getCheckInForList = (listId: number | undefined) => { - return attendeeCheckIns.find(ci => ci.check_in_list_id === listId); + return attendeeCheckIns.find(ci => + ci.check_in_list_id === listId + && (!attendee.event_occurrence_id || ci.event_occurrence_id === attendee.event_occurrence_id) + ); }; const isAttendeeEligibleForList = (list: CheckInList) => { diff --git a/frontend/src/components/common/Editor/Controls/InsertLiquidVariableControl.tsx b/frontend/src/components/common/Editor/Controls/InsertLiquidVariableControl.tsx index 411e23dd42..84db1cd813 100644 --- a/frontend/src/components/common/Editor/Controls/InsertLiquidVariableControl.tsx +++ b/frontend/src/components/common/Editor/Controls/InsertLiquidVariableControl.tsx @@ -15,6 +15,14 @@ interface TemplateVariable { category?: string; } +const OCCURRENCE_VARIABLES: TemplateVariable[] = [ + {label: t`Occurrence Start Date`, value: 'occurrence.start_date', description: t`Start date of the occurrence`, category: t`Occurrence`}, + {label: t`Occurrence Start Time`, value: 'occurrence.start_time', description: t`Start time of the occurrence`, category: t`Occurrence`}, + {label: t`Occurrence End Date`, value: 'occurrence.end_date', description: t`End date of the occurrence`, category: t`Occurrence`}, + {label: t`Occurrence End Time`, value: 'occurrence.end_time', description: t`End time of the occurrence`, category: t`Occurrence`}, + {label: t`Occurrence Label`, value: 'occurrence.label', description: t`Label for the occurrence`, category: t`Occurrence`}, +]; + const TEMPLATE_VARIABLES: Record = { order_confirmation: [ // Order Information @@ -39,6 +47,7 @@ const TEMPLATE_VARIABLES: Record = { {label: t`Event Timezone`, value: 'event.timezone', description: t`Event timezone`, category: t`Event`}, {label: t`Event Venue`, value: 'event.location_details.venue_name', description: t`The event venue`, category: t`Event`}, + ...OCCURRENCE_VARIABLES, // Organization {label: t`Organizer Name`, value: 'organizer.name', description: t`Event organizer name`, category: t`Organization`}, @@ -62,7 +71,7 @@ const TEMPLATE_VARIABLES: Record = { // Order Information {label: t`Order Payment Pending`, value: 'order.is_awaiting_offline_payment', description: t`True if payment pending`, category: t`Order`}, {label: t`Order Status`, value: 'order.status', description: t`Order Status`, category: t`Order`}, - {label: t`Offline Payment`, value: 'is_offline_payment', description: t`True if offline payment`, category: t`Order`}, + {label: t`Offline Payment`, value: 'order.is_offline_payment', description: t`True if offline payment`, category: t`Order`}, // Event Information {label: t`Event Title`, value: 'event.title', description: t`Name of the event`, category: t`Event`}, @@ -73,6 +82,8 @@ const TEMPLATE_VARIABLES: Record = { {label: t`Event Timezone`, value: 'event.timezone', description: t`Event timezone`, category: t`Event`}, {label: t`Event Venue`, value: 'event.location_details.venue_name', description: t`The event venue`, category: t`Event`}, + ...OCCURRENCE_VARIABLES, + // Organization {label: t`Organizer Name`, value: 'organizer.name', description: t`Event organizer name`, category: t`Organization`}, {label: t`Organizer Email`, value: 'organizer.email', description: t`Organizer email address`, category: t`Organization`}, @@ -82,6 +93,25 @@ const TEMPLATE_VARIABLES: Record = { {label: t`Offline Payment Instructions`, value: 'settings.offline_payment_instructions', description: t`How to pay offline`, category: t`Settings`}, {label: t`Post Checkout Message`, value: 'settings.post_checkout_message', description: t`Custom message after checkout`, category: t`Settings`}, ], + occurrence_cancellation: [ + {label: t`Event Title`, value: 'event.title', description: t`Name of the event`, category: t`Event`}, + {label: t`Event Date`, value: 'event.date', description: t`Date of the event`, category: t`Event`}, + {label: t`Event Time`, value: 'event.time', description: t`Start time of the event`, category: t`Event`}, + {label: t`Event Full Address`, value: 'event.full_address', description: t`The full event address`, category: t`Event`}, + {label: t`Event Description`, value: 'event.description', description: t`Event details`, category: t`Event`}, + {label: t`Event Timezone`, value: 'event.timezone', description: t`Event timezone`, category: t`Event`}, + {label: t`Event Venue`, value: 'event.location_details.venue_name', description: t`The event venue`, category: t`Event`}, + {label: t`Event URL`, value: 'event.url', description: t`Link to event homepage`, category: t`Event`}, + + ...OCCURRENCE_VARIABLES, + + {label: t`Refund Issued`, value: 'cancellation.refund_issued', description: t`Whether refunds are being processed`, category: t`Cancellation`}, + + {label: t`Organizer Name`, value: 'organizer.name', description: t`Event organizer name`, category: t`Organization`}, + {label: t`Organizer Email`, value: 'organizer.email', description: t`Organizer email address`, category: t`Organization`}, + + {label: t`Support Email`, value: 'settings.support_email', description: t`Contact email for support`, category: t`Settings`}, + ], }; export function InsertLiquidVariableControl({templateType = 'order_confirmation'}: InsertLiquidVariableControlProps) { diff --git a/frontend/src/components/common/EmailTemplateEditor/EmailTemplateEditor.tsx b/frontend/src/components/common/EmailTemplateEditor/EmailTemplateEditor.tsx index 8cd25b00cc..6b2f5234ba 100644 --- a/frontend/src/components/common/EmailTemplateEditor/EmailTemplateEditor.tsx +++ b/frontend/src/components/common/EmailTemplateEditor/EmailTemplateEditor.tsx @@ -54,7 +54,10 @@ export const EmailTemplateEditor = ({ if (!template && defaultTemplate && defaultTemplate.subject && defaultTemplate.body) { form.setFieldValue('subject', defaultTemplate.subject); form.setFieldValue('body', defaultTemplate.body); - form.setFieldValue('ctaLabel', templateType === 'order_confirmation' ? t`View Order` : t`View Ticket`); + const defaultCtaLabel = templateType === 'order_confirmation' ? t`View Order` + : templateType === 'occurrence_cancellation' ? t`View Event` + : t`View Ticket`; + form.setFieldValue('ctaLabel', defaultCtaLabel); form.setFieldValue('isActive', true); } }, [defaultTemplate, template]); @@ -66,7 +69,7 @@ export const EmailTemplateEditor = ({ subject: form.values.subject, body: form.values.body, template_type: templateType, - ctaLabel: form.values.ctaLabel || (templateType === 'order_confirmation' ? t`View Order` : t`View Ticket`), + ctaLabel: form.values.ctaLabel || (templateType === 'order_confirmation' ? t`View Order` : templateType === 'occurrence_cancellation' ? t`View Event` : t`View Ticket`), }); } }; @@ -93,6 +96,7 @@ export const EmailTemplateEditor = ({ const templateTypeLabels: Record = { 'order_confirmation': t`Order Confirmation`, 'attendee_ticket': t`Attendee Ticket`, + 'occurrence_cancellation': t`Date Cancellation`, }; return ( diff --git a/frontend/src/components/common/EmailTemplateSettings/EmailTemplateSettingsBase.tsx b/frontend/src/components/common/EmailTemplateSettings/EmailTemplateSettingsBase.tsx index dbc0fb44c7..f234c91906 100644 --- a/frontend/src/components/common/EmailTemplateSettings/EmailTemplateSettingsBase.tsx +++ b/frontend/src/components/common/EmailTemplateSettings/EmailTemplateSettingsBase.tsx @@ -1,6 +1,6 @@ import {useState} from 'react'; import {ActionIcon, Alert, Badge, Button, Group, LoadingOverlay, Modal, Paper, Stack, Text} from '@mantine/core'; -import {IconAlertCircle, IconEdit, IconInfoCircle, IconMail, IconPlus, IconTrash} from '@tabler/icons-react'; +import {IconAlertCircle, IconBrandStripe, IconEdit, IconInfoCircle, IconMail, IconPlus, IconTrash} from '@tabler/icons-react'; import {t, Trans} from '@lingui/macro'; import {useDisclosure} from '@mantine/hooks'; import {EmailTemplateEditor} from '../EmailTemplateEditor'; @@ -11,13 +11,15 @@ import { CreateEmailTemplateRequest, EmailTemplate, EmailTemplateType, + EventType, UpdateEmailTemplateRequest, DefaultEmailTemplate } from '../../../types'; import {Card} from '../Card'; import {HeadingWithDescription} from '../Card/CardHeading'; import {useGetAccount} from '../../../queries/useGetAccount'; -import {StripeConnectButton} from '../StripeConnectButton'; +import {NavLink} from 'react-router'; +import {useGetEvent} from '../../../queries/useGetEvent'; interface EmailTemplateSettingsBaseProps { // Context @@ -53,6 +55,7 @@ interface EmailTemplateSettingsBaseProps { onSaveSuccess?: () => void; onDeleteSuccess?: () => void; onError?: (error: any, message: string) => void; + eventType?: EventType; } export const EmailTemplateSettingsBase = ({ @@ -68,19 +71,25 @@ export const EmailTemplateSettingsBase = ({ onCreateTemplate, onSaveSuccess, onDeleteSuccess, - onError + onError, + eventType }: EmailTemplateSettingsBaseProps) => { const [editorOpened, {open: openEditor, close: closeEditor}] = useDisclosure(false); const [editingTemplate, setEditingTemplate] = useState(null); const [editingType, setEditingType] = useState('order_confirmation'); const handleFormError = useFormErrorResponseHandler(); const {data: account, isFetched: isAccountFetched} = useGetAccount(); + const eventQuery = useGetEvent(contextType === 'event' ? contextId : undefined); + const stripeOrganizerId = contextType === 'organizer' + ? contextId + : eventQuery.data?.organizer_id; const isAccountVerified = isAccountFetched && account?.is_account_email_confirmed; const accountRequiresManualVerification = isAccountFetched && account?.requires_manual_verification; const isModifyDisabled = !isAccountVerified || accountRequiresManualVerification; const orderConfirmationTemplate = templates.find(t => t.template_type === 'order_confirmation'); const attendeeTicketTemplate = templates.find(t => t.template_type === 'attendee_ticket'); + const occurrenceCancellationTemplate = templates.find(t => t.template_type === 'occurrence_cancellation'); const handleCreateTemplate = (type: EmailTemplateType) => { setEditingTemplate(null); @@ -199,11 +208,13 @@ export const EmailTemplateSettingsBase = ({ const templateTypeLabels: Record = { 'order_confirmation': t`Order Confirmation`, 'attendee_ticket': t`Attendee Ticket`, + 'occurrence_cancellation': t`Date Cancellation`, }; const templateDescriptions: Record = { 'order_confirmation': t`Sent to customers when they place an order`, 'attendee_ticket': t`Sent to each attendee with their ticket details`, + 'occurrence_cancellation': t`Sent to attendees when a scheduled date is cancelled`, }; const getTemplateStatusBadge = (template?: EmailTemplate) => { @@ -350,9 +361,18 @@ export const EmailTemplateSettingsBase = ({ {t`Due to the high risk of spam, you must connect a Stripe account before you can modify email templates. This is to ensure that all event organizers are verified and accountable.`} -
- -
+ {stripeOrganizerId && ( +
+ +
+ )} )} @@ -379,6 +399,15 @@ export const EmailTemplateSettingsBase = ({ label={templateTypeLabels.attendee_ticket} description={templateDescriptions.attendee_ticket} /> + + {(contextType === 'organizer' || eventType === EventType.RECURRING) && ( + + )}
diff --git a/frontend/src/components/common/EventCard/EventCard.module.scss b/frontend/src/components/common/EventCard/EventCard.module.scss index e1b4bfad6e..76a11548ed 100644 --- a/frontend/src/components/common/EventCard/EventCard.module.scss +++ b/frontend/src/components/common/EventCard/EventCard.module.scss @@ -174,6 +174,30 @@ margin-top: 2px; } +.recurringBadge { + position: absolute; + bottom: 10px; + right: 10px; + background: var(--mantine-color-white); + border-radius: var(--hi-radius-sm); + padding: 6px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08); + color: var(--mantine-color-primary-filled); +} + +.recurringLabel { + display: inline-flex; + align-items: center; + gap: 3px; + color: var(--mantine-color-primary-filled); + font-weight: 500; + padding-left: 6px; + border-left: 1px solid var(--mantine-color-gray-3); +} + .content { flex: 1; padding: 16px 20px; @@ -222,8 +246,12 @@ } .eventDate { + display: inline-flex; + align-items: center; + gap: 6px; color: var(--mantine-color-text); font-weight: 500; + white-space: nowrap; } .relativeDate { diff --git a/frontend/src/components/common/EventCard/index.tsx b/frontend/src/components/common/EventCard/index.tsx index 59ecbc963f..fdc362fa24 100644 --- a/frontend/src/components/common/EventCard/index.tsx +++ b/frontend/src/components/common/EventCard/index.tsx @@ -1,5 +1,5 @@ import {ActionIcon, Tooltip} from '@mantine/core'; -import {Event, IdParam, Product} from "../../../types.ts"; +import {Event, EventType, IdParam, Product} from "../../../types.ts"; import classes from "./EventCard.module.scss"; import {NavLink, useNavigate} from "react-router"; import { @@ -7,6 +7,7 @@ import { IconCopy, IconDotsVertical, IconEye, + IconRepeat, IconSettings, } from "@tabler/icons-react"; import {t} from "@lingui/macro" @@ -151,10 +152,6 @@ export function EventCard({event}: EventCardProps) { }, ]; - const monthShort = formatDateWithLocale(event.start_date, 'monthShort', event.timezone); - const dayOfMonth = formatDateWithLocale(event.start_date, 'dayOfMonth', event.timezone); - const shortDateTime = formatDateWithLocale(event.start_date, 'shortDateTime', event.timezone); - const relativeDateStr = relativeDate(event.start_date); const locationText = getLocationText(); const revenue = event?.statistics?.sales_total_gross || 0; @@ -165,6 +162,13 @@ export function EventCard({event}: EventCardProps) { const isEnded = event.lifecycle_status === 'ENDED'; const isDraft = event.status === 'DRAFT'; + const isRecurring = event.type === EventType.RECURRING; + + const displayDate = (isRecurring && event.next_occurrence_start_date) || event.start_date; + const monthShort = formatDateWithLocale(displayDate, 'monthShort', event.timezone); + const dayOfMonth = formatDateWithLocale(displayDate, 'dayOfMonth', event.timezone); + const shortDateTime = formatDateWithLocale(displayDate, 'shortDateTime', event.timezone); + const relativeDateStr = relativeDate(displayDate); return ( <> @@ -189,13 +193,27 @@ export function EventCard({event}: EventCardProps) { {dayOfMonth} {monthShort}
+ + {isRecurring && ( +
+ +
+ )}

{event.title}

- {shortDateTime} + + {shortDateTime} + {isRecurring && ( + + + {t`Recurring`} + + )} + ({relativeDateStr}) {locationText && ( <> diff --git a/frontend/src/components/common/EventDateRange/index.tsx b/frontend/src/components/common/EventDateRange/index.tsx index 89c2ba9fbb..e157928d0b 100644 --- a/frontend/src/components/common/EventDateRange/index.tsx +++ b/frontend/src/components/common/EventDateRange/index.tsx @@ -1,18 +1,20 @@ -import { Event } from "../../../types.ts"; -import { formatDateWithLocale } from "../../../utilites/dates.ts"; +import {t} from "@lingui/macro"; +import {Event, EventOccurrence, EventOccurrenceStatus, EventType} from "../../../types.ts"; +import {formatDateWithLocale} from "../../../utilites/dates.ts"; interface EventDateRangeProps { event: Event; + occurrence?: EventOccurrence; } -export const EventDateRange = ({ event }: EventDateRangeProps) => { - const isSameDay = event.end_date && event.start_date.substring(0, 10) === event.end_date.substring(0, 10); - const timezone = formatDateWithLocale(event.start_date, "timezone", event.timezone); +const formatRange = (startDate: string, endDate: string | undefined, tz: string) => { + const isSameDay = endDate && startDate.substring(0, 10) === endDate.substring(0, 10); + const timezone = formatDateWithLocale(startDate, "timezone", tz); if (isSameDay) { - const dayFormatted = formatDateWithLocale(event.start_date, "dayName", event.timezone); - const startTime = formatDateWithLocale(event.start_date, "timeOnly", event.timezone); - const endTime = formatDateWithLocale(event.end_date!, "timeOnly", event.timezone); + const dayFormatted = formatDateWithLocale(startDate, "dayName", tz); + const startTime = formatDateWithLocale(startDate, "timeOnly", tz); + const endTime = formatDateWithLocale(endDate!, "timeOnly", tz); return ( @@ -21,9 +23,9 @@ export const EventDateRange = ({ event }: EventDateRangeProps) => { ); } - const startDateFormatted = formatDateWithLocale(event.start_date, "fullDateTime", event.timezone); - const endDateFormatted = event.end_date - ? formatDateWithLocale(event.end_date, "fullDateTime", event.timezone) + const startDateFormatted = formatDateWithLocale(startDate, "fullDateTime", tz); + const endDateFormatted = endDate + ? formatDateWithLocale(endDate, "fullDateTime", tz) : null; return ( @@ -32,4 +34,33 @@ export const EventDateRange = ({ event }: EventDateRangeProps) => { {endDateFormatted && ` - ${endDateFormatted}`} {timezone} ); -} +}; + +export const EventDateRange = ({event, occurrence}: EventDateRangeProps) => { + if (occurrence) { + return formatRange(occurrence.start_date, occurrence.end_date, event.timezone); + } + + if (event.type === EventType.RECURRING) { + const activeOccurrences = (event.occurrences || []) + .filter(o => o.status === EventOccurrenceStatus.ACTIVE && !o.is_past) + .sort((a, b) => a.start_date.localeCompare(b.start_date)); + + if (activeOccurrences.length > 0) { + const next = activeOccurrences[0]; + if (activeOccurrences.length === 1) { + return formatRange(next.start_date, next.end_date, event.timezone); + } + const nextFormatted = formatDateWithLocale(next.start_date, "shortDateTime", event.timezone); + return ( + + {t`Next: ${nextFormatted}`} · {t`${activeOccurrences.length} upcoming dates`} + + ); + } + + return {t`No upcoming dates`}; + } + + return formatRange(event.start_date, event.end_date, event.timezone); +}; diff --git a/frontend/src/components/common/InlineOrderSummary/index.tsx b/frontend/src/components/common/InlineOrderSummary/index.tsx index 5f70770e4c..3c70f2aeda 100644 --- a/frontend/src/components/common/InlineOrderSummary/index.tsx +++ b/frontend/src/components/common/InlineOrderSummary/index.tsx @@ -80,8 +80,17 @@ export const InlineOrderSummary = ({
{event.title}
- {prettyDate(event.start_date, event.timezone, false)} + {prettyDate( + order.order_items?.[0]?.event_occurrence?.start_date || event.start_date, + event.timezone, + false + )}
+ {order.order_items?.[0]?.event_occurrence?.label && ( +
+ {order.order_items[0].event_occurrence.label} +
+ )} {location && (
{location}
)} diff --git a/frontend/src/components/common/JoinWaitlistButton/index.tsx b/frontend/src/components/common/JoinWaitlistButton/index.tsx index 30368db701..4f3ac337ff 100644 --- a/frontend/src/components/common/JoinWaitlistButton/index.tsx +++ b/frontend/src/components/common/JoinWaitlistButton/index.tsx @@ -9,11 +9,12 @@ interface JoinWaitlistButtonProps { event: Event; productPriceId: IdParam; priceLabel?: string; + eventOccurrenceId?: IdParam; } -export const JoinWaitlistButton = ({product, event, productPriceId, priceLabel}: JoinWaitlistButtonProps) => { +export const JoinWaitlistButton = ({product, event, productPriceId, priceLabel, eventOccurrenceId}: JoinWaitlistButtonProps) => { const [modalOpen, {open: openModal, close: closeModal}] = useDisclosure(false); - const {joined: hasJoined, markJoined} = useWaitlistJoined(event.id, productPriceId); + const {joined: hasJoined, markJoined} = useWaitlistJoined(event.id, productPriceId, eventOccurrenceId); return ( <> @@ -37,6 +38,7 @@ export const JoinWaitlistButton = ({product, event, productPriceId, priceLabel}: event={event} productPriceId={productPriceId} priceLabel={priceLabel} + eventOccurrenceId={eventOccurrenceId} onSuccess={() => { markJoined(); closeModal(); diff --git a/frontend/src/components/common/OccurrenceAttendeesAndOrders/OccurrenceAttendeesAndOrders.module.scss b/frontend/src/components/common/OccurrenceAttendeesAndOrders/OccurrenceAttendeesAndOrders.module.scss new file mode 100644 index 0000000000..e09386ed52 --- /dev/null +++ b/frontend/src/components/common/OccurrenceAttendeesAndOrders/OccurrenceAttendeesAndOrders.module.scss @@ -0,0 +1,39 @@ +.tabsCard { + padding: 0; + + :global(.mantine-Tabs-list) { + padding: 0; + } + + :global(.mantine-Tabs-tab:first-of-type) { + border-top-left-radius: var(--hi-radius-lg); + } +} + +.tabCount { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + margin-left: 6px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + background: var(--mantine-color-gray-1); + color: var(--mantine-color-gray-7); +} + +.viewAllLink { + margin-left: auto; + display: flex; + align-items: center; + padding-right: 12px; +} + +@media (max-width: 768px) { + .viewAllLink { + display: none; + } +} diff --git a/frontend/src/components/common/OccurrenceAttendeesAndOrders/index.tsx b/frontend/src/components/common/OccurrenceAttendeesAndOrders/index.tsx new file mode 100644 index 0000000000..4d59ca4394 --- /dev/null +++ b/frontend/src/components/common/OccurrenceAttendeesAndOrders/index.tsx @@ -0,0 +1,97 @@ +import {t} from "@lingui/macro"; +import {Anchor, Tabs, Text} from "@mantine/core"; +import {IconReceipt, IconUsers} from "@tabler/icons-react"; +import {useState} from "react"; +import {useNavigate, useParams} from "react-router"; +import {AttendeeTable} from "../AttendeeTable"; +import {OrdersTable} from "../OrdersTable"; +import {Card} from "../Card"; +import {useGetAttendees} from "../../../queries/useGetAttendees.ts"; +import {useGetEventOrders} from "../../../queries/useGetEventOrders.ts"; +import {useGetEvent} from "../../../queries/useGetEvent.ts"; +import {IdParam, QueryFilterOperator, QueryFilters} from "../../../types.ts"; +import classes from './OccurrenceAttendeesAndOrders.module.scss'; + +interface OccurrenceAttendeesAndOrdersProps { + occurrenceId: IdParam; + perPage?: number; + onNavigateAway?: () => void; +} + +export const OccurrenceAttendeesAndOrders = ({occurrenceId, perPage = 10, onNavigateAway}: OccurrenceAttendeesAndOrdersProps) => { + const {eventId} = useParams(); + const navigate = useNavigate(); + const {data: event} = useGetEvent(eventId); + const [activeTab, setActiveTab] = useState('attendees'); + + const filters: QueryFilters = { + pageNumber: 1, + perPage, + sortBy: 'created_at', + sortDirection: 'desc', + filterFields: { + event_occurrence_id: {operator: QueryFilterOperator.Equals, value: String(occurrenceId)}, + }, + }; + + const attendeesQuery = useGetAttendees(eventId, filters); + const ordersQuery = useGetEventOrders(eventId, filters); + + const attendeeCount = attendeesQuery.data?.meta?.total ?? 0; + const orderCount = ordersQuery.data?.meta?.total ?? 0; + + const handleNavigate = (path: string) => { + onNavigateAway?.(); + navigate(path); + }; + + const viewAllPath = activeTab === 'orders' + ? `/manage/event/${eventId}/orders?filterFields[event_occurrence_id][eq]=${occurrenceId}` + : `/manage/event/${eventId}/attendees?filterFields[event_occurrence_id][eq]=${occurrenceId}`; + + if (!event) return null; + + return ( + + + + }> + {t`Recent Attendees`} + {attendeeCount > 0 && {attendeeCount}} + + }> + {t`Recent Orders`} + {orderCount > 0 && {orderCount}} + +
+ handleNavigate(viewAllPath)}> + {t`View All`} + +
+
+ + + {attendeesQuery.data?.data && attendeeCount > 0 && ( + + )} + {attendeesQuery.data?.data && attendeeCount === 0 && ( + + {t`No attendees yet for this date.`} + + )} + + + + {ordersQuery.data?.data && orderCount > 0 && ( + + )} + {ordersQuery.data?.data && orderCount === 0 && ( + + {t`No orders yet for this date.`} + + )} + +
+
+ ); +}; diff --git a/frontend/src/components/common/OccurrenceSelect/index.tsx b/frontend/src/components/common/OccurrenceSelect/index.tsx new file mode 100644 index 0000000000..38311ed4c9 --- /dev/null +++ b/frontend/src/components/common/OccurrenceSelect/index.tsx @@ -0,0 +1,172 @@ +import {CSSProperties, useMemo, useState} from "react"; +import {Combobox, InputBase, ScrollArea, Text, useCombobox} from "@mantine/core"; +import {IconCalendar, IconSearch} from "@tabler/icons-react"; +import {t} from "@lingui/macro"; +import {EventOccurrence} from "../../../types.ts"; +import {filterAndGroupOccurrences, formatOccurrenceLabel, isToday} from "./occurrenceSelectUtils.ts"; + +interface OccurrenceSelectProps { + occurrences: EventOccurrence[]; + timezone: string; + value: string | null; + onChange: (value: string | null) => void; + placeholder?: string; + clearable?: boolean; + label?: string; + description?: string; + size?: 'xs' | 'sm' | 'md'; + allLabel?: string; + filterCancelled?: boolean; + style?: CSSProperties; +} + +const MAX_VISIBLE = 50; + +export const OccurrenceSelect = ({ + occurrences, + timezone: tz, + value, + onChange, + placeholder, + clearable = false, + label, + description, + size = 'sm', + allLabel, + filterCancelled = true, + style, +}: OccurrenceSelectProps) => { + const [search, setSearch] = useState(''); + const combobox = useCombobox({ + onDropdownClose: () => { + setSearch(''); + combobox.resetSelectedOption(); + }, + onDropdownOpen: () => { + combobox.focusSearchInput(); + }, + }); + + const {grouped, totalFiltered, totalAvailable} = useMemo( + () => filterAndGroupOccurrences(occurrences, { + search, + tz, + filterCancelled, + maxVisible: MAX_VISIBLE, + }), + [occurrences, tz, search, filterCancelled], + ); + + const selectedOcc = value + ? occurrences.find(o => String(o.id) === value) + : null; + + const displayValue = selectedOcc + ? formatOccurrenceLabel(selectedOcc, tz) + : (value === '' && allLabel) ? allLabel : null; + + return ( +
+ { + if (val === '__all__') { + onChange(''); + } else if (val === '__clear__') { + onChange(null); + } else { + onChange(val); + } + combobox.closeDropdown(); + }} + > + + } + rightSection={} + rightSectionPointerEvents="none" + onClick={() => combobox.toggleDropdown()} + > + + {displayValue || ( + + {placeholder || t`Select occurrence`} + + )} + + + + + + setSearch(event.currentTarget.value)} + placeholder={t`Search dates...`} + leftSection={} + /> + + + {allLabel && ( + + {allLabel} + + )} + + {clearable && value && ( + + {t`Clear`} + + )} + + {grouped.map(group => ( + + {group.items.map(occ => { + const isTodayOcc = isToday(occ, tz); + return ( + + + {isTodayOcc && `${t`Today`} — `} + {formatOccurrenceLabel(occ, tz)} + + + ); + })} + + ))} + + {totalFiltered === 0 && ( + {t`No dates match your search`} + )} + + {totalFiltered >= MAX_VISIBLE && totalAvailable > MAX_VISIBLE && ( + + {t`Showing ${MAX_VISIBLE} of ${totalAvailable} dates. Type to search.`} + + )} + + + + +
+ ); +}; diff --git a/frontend/src/components/common/OccurrenceSelect/occurrenceSelectUtils.ts b/frontend/src/components/common/OccurrenceSelect/occurrenceSelectUtils.ts new file mode 100644 index 0000000000..8c9941f824 --- /dev/null +++ b/frontend/src/components/common/OccurrenceSelect/occurrenceSelectUtils.ts @@ -0,0 +1,96 @@ +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; +import {EventOccurrence} from "../../../types.ts"; +import {formatDateWithLocale} from "../../../utilites/dates.ts"; + +dayjs.extend(utc); +dayjs.extend(timezone); + +/** + * Pure presentation + filtering helpers shared by occurrence pickers + * (OccurrenceSelect, check-in filter pill). Keeps rendering in the consumer. + */ + +export const formatOccurrenceLabel = (occ: EventOccurrence, tz: string): string => { + const date = formatDateWithLocale(occ.start_date, 'shortDate', tz); + const time = formatDateWithLocale(occ.start_date, 'timeOnly', tz); + return date + ' ' + time + (occ.label ? ` — ${occ.label}` : ''); +}; + +export const getMonthKey = (occ: EventOccurrence, tz: string): string => + dayjs.utc(occ.start_date).tz(tz).format('YYYY-MM'); + +export const getMonthLabel = (monthKey: string): string => + dayjs(monthKey + '-01').format('MMMM YYYY'); + +export const isToday = (occ: EventOccurrence, tz: string): boolean => { + const occDate = dayjs.utc(occ.start_date).tz(tz).format('YYYY-MM-DD'); + const today = dayjs().tz(tz).format('YYYY-MM-DD'); + return occDate === today; +}; + +export interface OccurrenceGroup { + key: string; + label: string; + items: EventOccurrence[]; +} + +export interface FilterAndGroupResult { + grouped: OccurrenceGroup[]; + /** Total occurrences after cancellation filter, before search/visibility cap. */ + totalAvailable: number; + /** Matching occurrences after the search filter is applied. */ + totalFiltered: number; + /** Whether the visible list was truncated by maxVisible. */ + truncated: boolean; +} + +interface FilterOptions { + search?: string; + tz: string; + filterCancelled?: boolean; + filterPast?: boolean; + maxVisible?: number; +} + +/** + * Filter, group by month (in the event's tz), and cap the visible entries. + * Consumers use the `truncated` flag to show a "type to search" hint. + */ +export const filterAndGroupOccurrences = ( + occurrences: EventOccurrence[], + {search, tz, filterCancelled = true, filterPast = false, maxVisible = 50}: FilterOptions, +): FilterAndGroupResult => { + let items = occurrences; + if (filterCancelled) { + items = items.filter(o => o.status !== 'CANCELLED'); + } + if (filterPast) { + items = items.filter(o => !o.is_past); + } + const totalAvailable = items.length; + + const q = search?.trim().toLowerCase(); + if (q) { + items = items.filter(occ => formatOccurrenceLabel(occ, tz).toLowerCase().includes(q)); + } + const totalFiltered = items.length; + + const truncated = items.length > maxVisible; + const visible = items.slice(0, maxVisible); + + const map = new Map(); + for (const occ of visible) { + const key = getMonthKey(occ, tz); + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(occ); + } + + const grouped: OccurrenceGroup[] = []; + for (const [key, items] of map) { + grouped.push({key, label: getMonthLabel(key), items}); + } + + return {grouped, totalAvailable, totalFiltered, truncated}; +}; diff --git a/frontend/src/components/common/OrderDetails/index.tsx b/frontend/src/components/common/OrderDetails/index.tsx index 70d2b9cc37..732a3347f4 100644 --- a/frontend/src/components/common/OrderDetails/index.tsx +++ b/frontend/src/components/common/OrderDetails/index.tsx @@ -1,5 +1,5 @@ import {Anchor, Tooltip} from "@mantine/core"; -import {prettyDate, relativeDate} from "../../../utilites/dates.ts"; +import {formatDateWithLocale, prettyDate, relativeDate} from "../../../utilites/dates.ts"; import {OrderStatusBadge} from "../OrderStatusBadge"; import {Currency} from "../Currency"; import {Card, CardVariant} from "../Card"; @@ -16,6 +16,11 @@ export const OrderDetails = ({order, event, cardVariant = 'lightGray', style = { cardVariant?: CardVariant, style?: React.CSSProperties }) => { + const occurrenceItems = order.order_items?.filter(item => item.event_occurrence) ?? []; + const uniqueOccurrences = Array.from( + new Map(occurrenceItems.map(item => [item.event_occurrence!.id, item.event_occurrence!])).values() + ); + return (
@@ -46,6 +51,23 @@ export const OrderDetails = ({order, event, cardVariant = 'lightGray', style = {
+ {uniqueOccurrences.length > 0 && ( +
+
+ {uniqueOccurrences.length === 1 ? t`Occurrence` : t`Occurrences`} +
+
+ {uniqueOccurrences.map(occurrence => ( +
+ {formatDateWithLocale(occurrence.start_date, 'shortDate', event.timezone)} + {' '} + {formatDateWithLocale(occurrence.start_date, 'timeOnly', event.timezone)} + {occurrence.label && ` · ${occurrence.label}`} +
+ ))} +
+
+ )}
{t`Status`} diff --git a/frontend/src/components/common/OrderSummary/OrderSummary.module.scss b/frontend/src/components/common/OrderSummary/OrderSummary.module.scss index e55fae1c15..40dec7fad8 100644 --- a/frontend/src/components/common/OrderSummary/OrderSummary.module.scss +++ b/frontend/src/components/common/OrderSummary/OrderSummary.module.scss @@ -20,6 +20,12 @@ .total { font-size: 1.1em; } + + .occurrenceMeta { + margin-top: 4px; + font-size: 12px; + color: var(--mantine-color-dimmed); + } } .itemValue { diff --git a/frontend/src/components/common/OrderSummary/index.tsx b/frontend/src/components/common/OrderSummary/index.tsx index 2158757c21..e3ccc2f185 100644 --- a/frontend/src/components/common/OrderSummary/index.tsx +++ b/frontend/src/components/common/OrderSummary/index.tsx @@ -2,6 +2,7 @@ import {Event, Order} from "../../../types.ts"; import classes from "./OrderSummary.module.scss"; import {Currency} from "../Currency"; import {t} from "@lingui/macro"; +import {formatDateWithLocale} from "../../../utilites/dates.ts"; interface OrderSummaryProps { event: Event, @@ -14,10 +15,21 @@ export const OrderSummary = ({event, order, showFreeWhenZeroTotal = true}: Order
{order?.order_items?.map(item => { + const occurrence = item.event_occurrence; return (
- {/* eslint-disable-next-line lingui/no-unlocalized-strings */} -
{item.quantity} x {item.item_name}
+
+ {/* eslint-disable-next-line lingui/no-unlocalized-strings */} +
{item.quantity} x {item.item_name}
+ {occurrence && ( +
+ {formatDateWithLocale(occurrence.start_date, 'shortDate', event.timezone)} + {' '} + {formatDateWithLocale(occurrence.start_date, 'timeOnly', event.timezone)} + {occurrence.label && ` · ${occurrence.label}`} +
+ )} +
{!!item.price_before_discount && (
diff --git a/frontend/src/components/common/OrdersTable/OrdersTable.module.scss b/frontend/src/components/common/OrdersTable/OrdersTable.module.scss index 4cbb258c99..fb84eaa894 100644 --- a/frontend/src/components/common/OrdersTable/OrdersTable.module.scss +++ b/frontend/src/components/common/OrdersTable/OrdersTable.module.scss @@ -110,6 +110,17 @@ line-height: 1.3; } +// Occurrence Chip +.occurrenceChip { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + line-height: 1.3; + color: var(--mantine-primary-color-filled); + white-space: nowrap; +} + // Items Section .itemsBadge { display: inline-flex; diff --git a/frontend/src/components/common/OrdersTable/index.tsx b/frontend/src/components/common/OrdersTable/index.tsx index 78da39a7a1..554e922810 100644 --- a/frontend/src/components/common/OrdersTable/index.tsx +++ b/frontend/src/components/common/OrdersTable/index.tsx @@ -4,6 +4,7 @@ import {Event, IdParam, Invoice, MessageType, Order} from "../../../types.ts"; import { IconAlertCircle, IconBasketCog, + IconCalendarEvent, IconCash, IconCheck, IconClock, @@ -23,7 +24,7 @@ import { IconTrash, IconX } from "@tabler/icons-react"; -import {relativeDate} from "../../../utilites/dates.ts"; +import {formatDateWithLocale, relativeDate} from "../../../utilites/dates.ts"; import {ManageOrderModal} from "../../modals/ManageOrderModal"; import {useClipboard, useDisclosure} from "@mantine/hooks"; import {useMemo, useState} from "react"; @@ -49,9 +50,10 @@ import {eventCheckoutUrl} from "../../../utilites/urlHelper.ts"; interface OrdersTableProps { event: Event, orders: Order[]; + compact?: boolean; } -export const OrdersTable = ({orders, event}: OrdersTableProps) => { +export const OrdersTable = ({orders, event, compact}: OrdersTableProps) => { const [isViewModalOpen, viewModal] = useDisclosure(false); const [isCancelModalOpen, cancelModal] = useDisclosure(false); const [isMessageModalOpen, messageModal] = useDisclosure(false); @@ -278,6 +280,7 @@ export const OrdersTable = ({orders, event}: OrdersTableProps) => { enableHiding: true, cell: (info: CellContext) => { const order = info.row.original; + const occurrence = order.order_items?.[0]?.event_occurrence; return (
{ > {order.public_id} + {occurrence && event?.timezone && ( + + + {formatDateWithLocale(occurrence.start_date, 'shortDate', event.timezone)} + {' '} + {formatDateWithLocale(occurrence.start_date, 'timeOnly', event.timezone)} + {occurrence.label && ` · ${occurrence.label}`} + + )}
{relativeDate(order.created_at)} @@ -478,8 +490,10 @@ export const OrdersTable = ({orders, event}: OrdersTableProps) => { data={orders} columns={columns} storageKey="orders-table" - enableColumnVisibility={true} - renderColumnVisibilityToggle={(table) => } + enableColumnVisibility={!compact} + renderColumnVisibilityToggle={!compact ? (table) => : undefined} + hideHeader={compact} + noCard={compact} /> {orderId && ( <> diff --git a/frontend/src/components/common/ProductPriceAvailability/index.tsx b/frontend/src/components/common/ProductPriceAvailability/index.tsx index 552698cfb2..ca31b49950 100644 --- a/frontend/src/components/common/ProductPriceAvailability/index.tsx +++ b/frontend/src/components/common/ProductPriceAvailability/index.tsx @@ -1,4 +1,4 @@ -import {Event, Product, ProductPrice} from "../../../types.ts"; +import {Event, EventType, IdParam, Product, ProductPrice} from "../../../types.ts"; import {t} from "@lingui/macro"; import {Tooltip} from "@mantine/core"; import {prettyDate, relativeDate} from "../../../utilites/dates.ts"; @@ -9,12 +9,13 @@ interface ProductPriceSaleDateMessageProps { price: ProductPrice; event: Event; product: Product; + eventOccurrenceId?: IdParam; } -const ProductPriceSaleDateMessage = ({price, event, product}: ProductPriceSaleDateMessageProps) => { +const ProductPriceSaleDateMessage = ({price, event, product, eventOccurrenceId}: ProductPriceSaleDateMessageProps) => { if (price.is_sold_out) { - if (product.waitlist_enabled) { - return ; + if (product.waitlist_enabled && (event.type !== EventType.RECURRING || eventOccurrenceId)) { + return ; } return t`Sold out`; } @@ -40,12 +41,13 @@ const ProductPriceSaleDateMessage = ({price, event, product}: ProductPriceSaleDa interface ProductAvailabilityMessageProps { product: Product; event: Event; + eventOccurrenceId?: IdParam; } -export const ProductAvailabilityMessage = ({product, event}: ProductAvailabilityMessageProps) => { +export const ProductAvailabilityMessage = ({product, event, eventOccurrenceId}: ProductAvailabilityMessageProps) => { if (product.is_sold_out) { - if (product.waitlist_enabled && product.type !== 'TIERED') { - return ; + if (product.waitlist_enabled && (event.type !== EventType.RECURRING || eventOccurrenceId) && product.type !== 'TIERED') { + return ; } return t`Sold out`; } @@ -70,13 +72,14 @@ interface ProductAndPriceAvailabilityProps { product: Product; price: ProductPrice; event: Event; + eventOccurrenceId?: IdParam; } -export const ProductPriceAvailability = ({product, price, event}: ProductAndPriceAvailabilityProps) => { +export const ProductPriceAvailability = ({product, price, event, eventOccurrenceId}: ProductAndPriceAvailabilityProps) => { if (product.type === 'TIERED') { - return + return } - return + return } diff --git a/frontend/src/components/common/ReportTable/index.tsx b/frontend/src/components/common/ReportTable/index.tsx index 69895ac79e..a6edd57317 100644 --- a/frontend/src/components/common/ReportTable/index.tsx +++ b/frontend/src/components/common/ReportTable/index.tsx @@ -9,11 +9,13 @@ import {Table, TableHead} from "../Table"; import '@mantine/dates/styles.css'; import {useGetEventReport} from "../../../queries/useGetEventReport.ts"; import {useParams} from "react-router"; -import {Event} from "../../../types.ts"; +import {Event, EventType, IdParam} from "../../../types.ts"; import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; import {NoResultsSplash} from "../NoResultsSplash"; +import {OccurrenceSelect} from "../OccurrenceSelect"; +import {useGetEventOccurrences} from "../../../queries/useGetEventOccurrences.ts"; import classes from './ReportTable.module.scss'; dayjs.extend(utc); @@ -32,6 +34,7 @@ interface ReportProps { event: Event isLoading?: boolean; showDateFilter?: boolean; + showOccurrenceFilter?: boolean; defaultStartDate?: Date; defaultEndDate?: Date; onDateRangeChange?: (range: [Date | null, Date | null]) => void; @@ -57,6 +60,7 @@ const ReportTable = >({ title, columns, showDateFilter = true, + showOccurrenceFilter = true, defaultStartDate = new Date(new Date().setMonth(new Date().getMonth() - 3)), defaultEndDate = new Date(), onDateRangeChange, @@ -73,8 +77,19 @@ const ReportTable = >({ const [showDatePickerInput, setShowDatePickerInput] = useState(showCustomDatePicker); const [sortField, setSortField] = useState(null); const [sortDirection, setSortDirection] = useState<'asc' | 'desc' | null>(null); + const [selectedOccurrenceId, setSelectedOccurrenceId] = useState(undefined); const {reportType, eventId} = useParams(); - const reportQuery = useGetEventReport(eventId, reportType, dateRange[0], dateRange[1]); + + const isRecurring = event?.type === EventType.RECURRING; + const occurrencesQuery = useGetEventOccurrences( + eventId, + {pageNumber: 1, perPage: 500}, + true, + {includeStats: false}, + ); + const occurrences = occurrencesQuery?.data?.data || []; + + const reportQuery = useGetEventReport(eventId, reportType, dateRange[0], dateRange[1], selectedOccurrenceId); const data = (reportQuery.data || []) as T[]; const calculateDateRange = (period: string): [Date | null, Date | null] => { @@ -255,6 +270,16 @@ const ReportTable = >({ {title} + {isRecurring && showOccurrenceFilter && occurrences.length > 0 && event?.timezone && ( + setSelectedOccurrenceId(value ? Number(value) : undefined)} + placeholder={t`All Occurrences`} + clearable + /> + )} {showDateFilter && ( { +interface StatBoxesProps { + occurrenceId?: IdParam; +} + +export const StatBoxes = ({occurrenceId}: StatBoxesProps = {}) => { const {eventId} = useParams(); - const eventStatsQuery = useGetEventStats(eventId); + const eventStatsQuery = useGetEventStats(eventId, {occurrenceId}); const eventQuery = useGetEvent(eventId); const event = eventQuery?.data; const {data: eventStats} = eventStatsQuery; @@ -78,9 +83,13 @@ export const StatBoxes = () => { } ]; + const filteredData = occurrenceId + ? data.filter((stat) => stat.description !== t`Page views`) + : data; + return (
- {data.map((stat) => ( + {filteredData.map((stat) => ( = ({ - buttonText, - buttonIcon = , - variant = 'light', - size = 'sm', - fullWidth = false, - className, - platform -}) => { - const [fetchStripeDetails, setFetchStripeDetails] = useState(false); - const [isReturningFromStripe, setIsReturningFromStripe] = useState(false); - const accountQuery = useGetAccount(); - const account = accountQuery.data; - - // For Hi.Events, use the new platform parameter for Ireland migration - // For open-source, use existing logic (no platform parameter) - const platformToUse = isHiEvents() ? platform || 'ie' : undefined; - - const stripeDetailsQuery = useCreateOrGetStripeConnectDetails( - account?.id || '', - (!!account?.stripe_account_id || fetchStripeDetails) && !!account?.id, - platformToUse - ); - - const stripeDetails = stripeDetailsQuery.data; - - useEffect(() => { - if (typeof window === 'undefined') { - return; - } - setIsReturningFromStripe( - window.location.search.includes('is_return') || - window.location.search.includes('is_refresh') - ); - }, []); - - useEffect(() => { - if (fetchStripeDetails && !stripeDetailsQuery.isLoading && stripeDetails) { - setFetchStripeDetails(false); - showSuccess(t`Redirecting to Stripe...`); - window.location.href = String(stripeDetails.connect_url); - } - }, [fetchStripeDetails, stripeDetailsQuery.isLoading, stripeDetails]); - - const handleClick = () => { - if (!stripeDetails) { - setFetchStripeDetails(true); - } else { - if (stripeDetails.is_connect_setup_complete) { - showSuccess(t`Stripe setup is already complete.`); - return; - } - - if (typeof window !== 'undefined') { - showSuccess(t`Redirecting to Stripe...`); - window.location.href = String(stripeDetails.connect_url); - } - } - }; - - // Determine button text - const getButtonText = () => { - if (buttonText) return buttonText; - - if (!isReturningFromStripe && !account?.stripe_account_id) { - return t`Connect with Stripe`; - } - return t`Complete Stripe Setup`; - }; - - return ( - - ); -}; diff --git a/frontend/src/components/common/TanStackTable/index.tsx b/frontend/src/components/common/TanStackTable/index.tsx index 74596d9cc7..9e272ac1b0 100644 --- a/frontend/src/components/common/TanStackTable/index.tsx +++ b/frontend/src/components/common/TanStackTable/index.tsx @@ -20,6 +20,9 @@ interface TanStackTableProps { storageKey?: string; enableColumnVisibility?: boolean; renderColumnVisibilityToggle?: (table: ReturnType>) => React.ReactNode; + hideHeader?: boolean; + noCard?: boolean; + rowStyle?: (row: TData) => React.CSSProperties | undefined; } export function TanStackTable({ @@ -28,6 +31,9 @@ export function TanStackTable({ storageKey, enableColumnVisibility = false, renderColumnVisibilityToggle, + hideHeader = false, + noCard = false, + rowStyle, }: TanStackTableProps) { const [columnVisibility, setColumnVisibility] = useState(() => { if (storageKey && enableColumnVisibility) { @@ -60,6 +66,74 @@ export function TanStackTable({ } }, [columnVisibility, storageKey, enableColumnVisibility]); + const tableContent = ( + + + {!hideHeader && ( + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const columnMeta = header.column.columnDef.meta as TanStackTableColumnMeta | undefined; + const stickyClass = columnMeta?.sticky === 'left' + ? classes.stickyLeft + : columnMeta?.sticky === 'right' + ? classes.stickyRight + : ''; + + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + )} + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const columnMeta = cell.column.columnDef.meta as TanStackTableColumnMeta | undefined; + const stickyClass = columnMeta?.sticky === 'left' + ? classes.stickyLeft + : columnMeta?.sticky === 'right' + ? classes.stickyRight + : ''; + + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + ))} + + + + ); + return (
{enableColumnVisibility && renderColumnVisibilityToggle && ( @@ -67,71 +141,7 @@ export function TanStackTable({ {renderColumnVisibilityToggle(table)}
)} - - - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const columnMeta = header.column.columnDef.meta as TanStackTableColumnMeta | undefined; - const stickyClass = columnMeta?.sticky === 'left' - ? classes.stickyLeft - : columnMeta?.sticky === 'right' - ? classes.stickyRight - : ''; - - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - - ); - })} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => { - const columnMeta = cell.column.columnDef.meta as TanStackTableColumnMeta | undefined; - const stickyClass = columnMeta?.sticky === 'left' - ? classes.stickyLeft - : columnMeta?.sticky === 'right' - ? classes.stickyRight - : ''; - - return ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ); - })} - - ))} - - - - + {noCard ? tableContent : {tableContent}}
); } diff --git a/frontend/src/components/common/ToolBar/ToolBar.module.scss b/frontend/src/components/common/ToolBar/ToolBar.module.scss index a481b44c18..90c16104f5 100644 --- a/frontend/src/components/common/ToolBar/ToolBar.module.scss +++ b/frontend/src/components/common/ToolBar/ToolBar.module.scss @@ -1,55 +1,60 @@ @use "../../../styles/mixins"; -.card { +.toolbar { container-type: inline-size; margin-bottom: 1rem; +} + +// Row 1: search + action buttons +.rowPrimary { + display: flex; + align-items: center; + gap: 8px; - .wrapper { - display: flex; - gap: 10px; - align-items: center; - place-content: space-between; - - @include mixins.respond-below(sm, true) { - flex-direction: column; - align-items: flex-start; - width: 100%; - } - - .searchBar { - margin-bottom: 0 !important; - width: 100%; - flex: 1; - min-width: 0; // Prevents flex item from overflowing - } - - .filterAndActions { - display: flex; - gap: 10px; - align-items: center; - place-self: flex-end; - - @include mixins.respond-below(sm, true) { - width: 100%; - justify-content: flex-end; - } - } - - .filter { - display: flex; - align-items: center; - } - - .actions { - display: flex; - gap: 10px; - align-items: center; - place-self: flex-end; - flex-wrap: wrap; - } + @include mixins.respond-below(sm, true) { + flex-wrap: wrap; } +} + +.searchSlot { + flex: 1; + min-width: 0; + margin-bottom: 0 !important; +} + +.actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + margin-left: auto; +} + +// Row 2: sort + filters + result count +.rowFilters { + display: flex; + align-items: center; + gap: 8px; + padding-top: 10px; + flex-wrap: wrap; +} + +.filterSlot { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.resultCount { + font-size: 12px; + color: var(--mantine-color-dimmed); + margin-left: auto; + white-space: nowrap; + flex-shrink: 0; + align-self: center; - button { - height: 42px !important; + @include mixins.respond-below(md) { + display: none; } } diff --git a/frontend/src/components/common/ToolBar/index.tsx b/frontend/src/components/common/ToolBar/index.tsx index 5f8f7dd3e1..f28a34b9ce 100644 --- a/frontend/src/components/common/ToolBar/index.tsx +++ b/frontend/src/components/common/ToolBar/index.tsx @@ -1,43 +1,54 @@ import React from "react"; import {Card} from "../Card"; import classes from './ToolBar.module.scss'; -import {Group} from '@mantine/core'; +import {t} from "@lingui/macro"; interface ToolBarProps { children?: React.ReactNode[] | React.ReactNode; searchComponent?: () => React.ReactNode; filterComponent?: React.ReactNode; + resultCount?: number; + resultLabel?: string; className?: string; } export const ToolBar: React.FC = ({ - searchComponent, - filterComponent, - children, - className, - }) => { + searchComponent, + filterComponent, + children, + resultCount, + resultLabel, + className, +}) => { return ( - -
+ +
{searchComponent && ( -
+
{searchComponent()}
)} + {children && ( +
+ {children} +
+ )} +
- + {(filterComponent || resultCount !== undefined) && ( +
{filterComponent && ( -
+
{filterComponent}
)} - {children && ( -
- {children} -
+ {resultCount !== undefined && ( + + {resultCount.toLocaleString()} {resultLabel || t`results`} + )} - -
+
+ )} ); }; diff --git a/frontend/src/components/common/WaitlistTable/index.tsx b/frontend/src/components/common/WaitlistTable/index.tsx index 4796a2e3f4..b98a2093dd 100644 --- a/frontend/src/components/common/WaitlistTable/index.tsx +++ b/frontend/src/components/common/WaitlistTable/index.tsx @@ -4,8 +4,8 @@ import {IconDotsVertical, IconEye, IconSend, IconTrash} from "@tabler/icons-reac import {useMemo, useState} from "react"; import {CellContext} from "@tanstack/react-table"; import {useDisclosure} from "@mantine/hooks"; -import {IdParam, WaitlistEntry, WaitlistEntryStatus} from "../../../types.ts"; -import {relativeDate} from "../../../utilites/dates.ts"; +import {Event, EventType, IdParam, WaitlistEntry, WaitlistEntryStatus} from "../../../types.ts"; +import {prettyDate, relativeDate} from "../../../utilites/dates.ts"; import {NoResultsSplash} from "../NoResultsSplash"; import {confirmationDialog} from "../../../utilites/confirmationDialog.tsx"; import {useRemoveWaitlistEntry} from "../../../mutations/useRemoveWaitlistEntry.ts"; @@ -18,6 +18,7 @@ import classes from './WaitlistTable.module.scss'; interface WaitlistTableProps { eventId: IdParam; entries: WaitlistEntry[]; + event?: Event; } const statusLabelMap: Record string> = { @@ -84,7 +85,9 @@ const ActionMenu = ({entry, onOffer, onRemove, onViewOrder}: { ); }; -export const WaitlistTable = ({eventId, entries}: WaitlistTableProps) => { +export const WaitlistTable = ({eventId, entries, event}: WaitlistTableProps) => { + const isRecurring = event?.type === EventType.RECURRING; + const timezone = event?.timezone || 'UTC'; const removeMutation = useRemoveWaitlistEntry(); const offerMutation = useOfferSpecificWaitlistEntry(); const [isOrderModalOpen, orderModal] = useDisclosure(false); @@ -176,6 +179,27 @@ export const WaitlistTable = ({eventId, entries}: WaitlistTableProps) => { return label ? `${title} - ${label}` : title; } }, + ...(isRecurring ? [{ + id: 'occurrence', + header: t`Date`, + enableHiding: true, + cell: (info: CellContext) => { + const entry = info.row.original; + const occurrence = entry.event_occurrence; + if (!occurrence) { + return ; + } + const dateText = prettyDate(occurrence.start_date, timezone); + return ( + + {occurrence.label ? `${dateText} (${occurrence.label})` : dateText} + + ); + }, + meta: { + headerStyle: {minWidth: 180}, + }, + } as TanStackTableColumn] : []), { id: 'status', header: t`Status`, @@ -227,7 +251,7 @@ export const WaitlistTable = ({eventId, entries}: WaitlistTableProps) => { }, }, ], - [eventId] + [eventId, isRecurring, timezone] ); if (entries.length === 0) { diff --git a/frontend/src/components/forms/CheckInListForm/CheckInListForm.module.scss b/frontend/src/components/forms/CheckInListForm/CheckInListForm.module.scss new file mode 100644 index 0000000000..d0a974433d --- /dev/null +++ b/frontend/src/components/forms/CheckInListForm/CheckInListForm.module.scss @@ -0,0 +1,121 @@ +.advancedToggle { + display: flex; + align-items: center; + gap: 6px; + background: transparent; + border: none; + color: var(--hi-primary); + font-family: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + padding: 6px 8px; + margin: 4px 0 12px -8px; + border-radius: 6px; + transition: background 140ms ease; + + &:hover { + background: color-mix(in srgb, var(--hi-primary) 8%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--hi-primary); + outline-offset: 1px; + } + + .chevron { + transition: transform 180ms ease; + + &.chevronOpen { + transform: rotate(90deg); + } + } +} + +.visibilitySection { + margin-top: 20px; + padding: 16px; + background: var(--hi-color-gray); + border-radius: 12px; + border: 1px solid var(--hi-color-gray-2); +} + +.visibilityHeader { + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: 14px; +} + +.visibilityIcon { + width: 32px; + height: 32px; + border-radius: 8px; + background: #fff; + border: 1px solid var(--hi-color-gray-2); + display: flex; + align-items: center; + justify-content: center; + color: var(--hi-color-gray-dark); + flex-shrink: 0; +} + +.visibilityTitle { + font-size: 14px; + font-weight: 700; + color: var(--hi-text); + line-height: 1.3; + letter-spacing: -0.01em; +} + +.visibilityHint { + font-size: 12px; + color: var(--hi-color-gray-dark); + line-height: 1.4; + margin-top: 2px; +} + +.visibilityRows { + display: flex; + flex-direction: column; + gap: 4px; +} + +.visibilityRow { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + background: #fff; + border: 1px solid var(--hi-color-gray-2); + border-radius: 10px; +} + +.visibilityRowIcon { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--hi-color-gray); + display: flex; + align-items: center; + justify-content: center; + color: var(--hi-color-gray-dark); + flex-shrink: 0; +} + +.visibilityRowMain { + flex: 1; + min-width: 0; +} + +.visibilityRowLabel { + font-size: 13px; + font-weight: 600; + color: var(--hi-text); +} + +.visibilityRowDesc { + font-size: 12px; + color: var(--hi-color-gray-dark); + margin-top: 1px; +} diff --git a/frontend/src/components/forms/CheckInListForm/index.tsx b/frontend/src/components/forms/CheckInListForm/index.tsx index 7b57c56222..e90058ee0b 100644 --- a/frontend/src/components/forms/CheckInListForm/index.tsx +++ b/frontend/src/components/forms/CheckInListForm/index.tsx @@ -1,35 +1,100 @@ -import {Alert, Textarea, TextInput} from "@mantine/core"; -import {t} from "@lingui/macro"; +import {Collapse, Select, Switch, Textarea, TextInput} from "@mantine/core"; +import {t, Trans} from "@lingui/macro"; import {UseFormReturnType} from "@mantine/form"; -import {CheckInListRequest, ProductCategory, ProductType} from "../../../types.ts"; +import { + CheckInListRequest, + EventOccurrence, + EventType, + ProductCategory, + ProductType +} from "../../../types.ts"; import {InputGroup} from "../../common/InputGroup"; import {ProductSelector} from "../../common/ProductSelector"; -import {useEffect, useMemo} from "react"; -import {IconInfoCircle} from "@tabler/icons-react"; +import {Callout} from "../../common/Callout"; +import {useEffect, useMemo, useState} from "react"; +import { + IconChevronRight, + IconClipboardText, + IconEye, + IconMessageCircleQuestion, + IconReceipt2, +} from "@tabler/icons-react"; +import {formatDateWithLocale} from "../../../utilites/dates.ts"; +import classes from "./CheckInListForm.module.scss"; interface CheckInListFormProps { form: UseFormReturnType; productCategories: ProductCategory[]; + eventType?: EventType; + occurrences?: EventOccurrence[]; + timezone?: string; + isNewForOccurrence?: boolean; + hideIntro?: boolean; } -export const CheckInListForm = ({form, productCategories}: CheckInListFormProps) => { - const tickets = useMemo(() => { - return productCategories - .flatMap(category => category.products || []) - .filter(product => product.product_type === ProductType.Ticket); - }, [productCategories]); +const hasAdvancedValuesSet = (form: UseFormReturnType): boolean => { + return !!( + form.values.description + || form.values.activates_at + || form.values.expires_at + || form.values.public_show_attendee_notes === false + || form.values.public_show_question_answers === false + || form.values.public_show_order_details === false + ); +}; + +export const CheckInListForm = ({ + form, + productCategories, + eventType, + occurrences, + timezone, + isNewForOccurrence, + hideIntro, + }: CheckInListFormProps) => { + const isRecurring = eventType === EventType.RECURRING; + const activeOccurrences = useMemo(() => { + if (!isRecurring || !occurrences || !timezone) return []; + return occurrences.filter(o => o.status !== 'CANCELLED'); + }, [isRecurring, occurrences, timezone]); + + const occurrenceOptions = useMemo(() => { + if (!activeOccurrences.length || !timezone) return []; + return activeOccurrences.map(o => ({ + value: String(o.id), + label: formatDateWithLocale(o.start_date, 'shortDate', timezone) + + ' ' + formatDateWithLocale(o.start_date, 'timeOnly', timezone) + + (o.label ? ` — ${o.label}` : ''), + })); + }, [activeOccurrences, timezone]); + // Open advanced panel automatically if editing a list that already uses any of those options. + const [showAdvanced, setShowAdvanced] = useState(() => hasAdvancedValuesSet(form)); + + // UI mirror of "product_ids is empty" — default on for new lists. + const [scopeToAll, setScopeToAll] = useState( + () => !form.values.product_ids || form.values.product_ids.length === 0, + ); + + // Reflect late-hydrated values (edit modal sets product_ids in an effect). useEffect(() => { - if (tickets.length === 1 && (!form.values.product_ids || form.values.product_ids.length === 0)) { - form.setFieldValue('product_ids', [String(tickets[0].id)]); - } - }, [tickets]); + const hasProducts = (form.values.product_ids?.length ?? 0) > 0; + if (hasProducts && scopeToAll) setScopeToAll(false); + }, [form.values.product_ids]); + + const introTitle = isNewForOccurrence + ? t`Control who gets in for this date` + : t`Control who gets in, and when`; return ( <> - } color="blue" variant="light"> - {t`Check-in lists let you control entry across days, areas, or ticket types. You can share a secure check-in link with staff — no account required.`} - + {!hideIntro && ( + + + Split check-in across days, areas, or ticket types. Share the link with staff — no account needed on their end. + + + )} - { + const checked = e.currentTarget.checked; + setScopeToAll(checked); + if (checked) { + form.setFieldValue('product_ids', []); + } + }} /> -