diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000000..d238581275 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,183 @@ +name: Deploy to Vapor and Frontend + +on: + push: + branches: + - main + - develop + workflow_dispatch: + inputs: + environment: + description: 'Environment to deploy to' + required: true + default: 'staging' + type: choice + options: + - staging + - production + test_mode: + description: 'Run in test mode (no actual deployment)' + required: false + default: false + type: boolean + +jobs: + backend: + name: Deploy Backend + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + tools: composer:v2 + coverage: none + + - name: Prepare Laravel Environment + working-directory: ./backend + run: | + mkdir -p bootstrap/cache + chmod -R 775 bootstrap/cache + + - name: Prepare HTMLPurifier Cache Directory + working-directory: ./backend + run: | + mkdir -p storage/app/htmlpurifier + chmod -R 775 storage/app/htmlpurifier + + - name: Install Dependencies + working-directory: ./backend + run: composer install --no-dev --no-progress --no-scripts --optimize-autoloader + + - name: Install Vapor CLI + run: composer global require laravel/vapor-cli + + - name: Set Deployment Environment + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "VAPOR_ENV=${{ github.event.inputs.environment }}" >> "$GITHUB_ENV" + echo "TEST_MODE=${{ github.event.inputs.test_mode }}" >> "$GITHUB_ENV" + elif [[ "${{ github.ref_name }}" == "develop" ]]; then + echo "VAPOR_ENV=staging" >> "$GITHUB_ENV" + echo "TEST_MODE=false" >> "$GITHUB_ENV" + else + echo "VAPOR_ENV=production" >> "$GITHUB_ENV" + echo "TEST_MODE=false" >> "$GITHUB_ENV" + fi + + - name: Log Branch and Environment + run: | + echo "🚀 Deploying branch ${{ github.ref_name }} to Vapor environment: ${{ env.VAPOR_ENV }}" + echo "🧪 Test mode: ${{ env.TEST_MODE }}" + + - name: Validate Deployment Configuration + working-directory: ./backend + run: | + if [[ "${{ env.TEST_MODE }}" == "true" ]]; then + echo "✅ TEST MODE: Would deploy to ${{ env.VAPOR_ENV }} environment" + echo "vapor deploy ${{ env.VAPOR_ENV }} --dry-run" + exit 0 + fi + + - name: Deploy to Vapor + working-directory: ./backend + run: vapor deploy ${{ env.VAPOR_ENV }} + env: + VAPOR_API_TOKEN: ${{ secrets.VAPOR_API_TOKEN }} + + frontend: + name: Deploy Frontend + runs-on: ubuntu-latest + needs: backend + + steps: + - uses: actions/checkout@v3 + + - name: Set Deployment Environment + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + if [[ "${{ github.event.inputs.environment }}" == "staging" ]]; then + echo "DO_APP_ID=${{ secrets.DIGITALOCEAN_STAGING_APP_ID }}" >> "$GITHUB_ENV" + else + echo "DO_APP_ID=${{ secrets.DIGITALOCEAN_PRODUCTION_APP_ID }}" >> "$GITHUB_ENV" + fi + echo "TEST_MODE=${{ github.event.inputs.test_mode }}" >> "$GITHUB_ENV" + elif [[ "${{ github.ref_name }}" == "develop" ]]; then + echo "DO_APP_ID=${{ secrets.DIGITALOCEAN_STAGING_APP_ID }}" >> "$GITHUB_ENV" + echo "TEST_MODE=false" >> "$GITHUB_ENV" + else + echo "DO_APP_ID=${{ secrets.DIGITALOCEAN_PRODUCTION_APP_ID }}" >> "$GITHUB_ENV" + echo "TEST_MODE=false" >> "$GITHUB_ENV" + fi + + - name: Log Environment Settings + run: | + echo "🚀 Deploying frontend to DigitalOcean App: ${{ env.DO_APP_ID }}" + echo "🧪 Test mode: ${{ env.TEST_MODE }}" + + - name: Validate Deployment Configuration (Test Mode) + if: env.TEST_MODE == 'true' + run: | + echo "✅ TEST MODE: Would trigger deployment for DigitalOcean App: ${{ env.DO_APP_ID }}" + echo "curl -X POST 'https://api.digitalocean.com/v2/apps/${{ env.DO_APP_ID }}/deployments'" + exit 0 + + - name: Trigger Deployment on DigitalOcean + if: env.TEST_MODE != 'true' + id: trigger_deployment + run: | + RESPONSE=$(curl -s -o response.json -w "%{http_code}" -X POST "https://api.digitalocean.com/v2/apps/$DO_APP_ID/deployments" \ + -H "Authorization: Bearer ${{ secrets.DIGITALOCEAN_API_TOKEN }}" \ + -H "Content-Type: application/json") + + if [ "$RESPONSE" -ne 201 ] && [ "$RESPONSE" -ne 200 ]; then + ERROR_MSG=$(jq -r '.message // "Unknown error occurred."' response.json) + echo "❌ Failed to trigger deployment. HTTP Status: $RESPONSE. Error: $ERROR_MSG" + exit 1 + fi + + DEPLOYMENT_ID=$(jq -r '.deployment.id' response.json) + if [ "$DEPLOYMENT_ID" == "null" ]; then + echo "❌ Failed to extract deployment ID." + exit 1 + fi + + echo "::add-mask::$DEPLOYMENT_ID" + echo "✅ Deployment triggered successfully." + + echo "deployment_id=$DEPLOYMENT_ID" >> "$GITHUB_ENV" + + - name: Poll Deployment Status + if: env.TEST_MODE != 'true' + run: | + MAX_RETRIES=60 + SLEEP_TIME=10 + COUNTER=0 + + while [ $COUNTER -lt $MAX_RETRIES ]; do + RESPONSE=$(curl -s -X GET "https://api.digitalocean.com/v2/apps/$DO_APP_ID/deployments/${{ env.deployment_id }}" \ + -H "Authorization: Bearer ${{ secrets.DIGITALOCEAN_API_TOKEN }}" \ + -H "Content-Type: application/json") + + STATUS=$(echo "$RESPONSE" | jq -r '.deployment.phase') + + echo "🔄 Deployment Status: $STATUS" + + if [ "$STATUS" == "ACTIVE" ]; then + echo "✅ Deployment completed successfully." + exit 0 + elif [[ "$STATUS" == "FAILED" || "$STATUS" == "CANCELLED" ]]; then + echo "❌ Deployment failed or was cancelled." + exit 1 + fi + + COUNTER=$((COUNTER + 1)) + echo "⏳ Retrying in $SLEEP_TIME seconds... ($COUNTER/$MAX_RETRIES)" + sleep $SLEEP_TIME + done + + echo "⏰ Deployment timed out." + exit 1 diff --git a/.gitignore b/.gitignore index 4babc6f47d..b9e16bf8e7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ frontend/.env backend/.env todo.md + +.vercel \ No newline at end of file diff --git a/Dockerfile.all-in-one b/Dockerfile.all-in-one index 60bf629a44..5b72b734e5 100644 --- a/Dockerfile.all-in-one +++ b/Dockerfile.all-in-one @@ -21,17 +21,19 @@ RUN apk add --no-cache nodejs yarn nginx supervisor COPY --from=node-frontend /app/frontend /app/frontend COPY ./backend /app/backend -RUN chown -R www-data:www-data /app/backend \ +RUN mkdir -p /app/backend/bootstrap/cache \ + && mkdir -p /app/backend/storage \ + && chown -R www-data:www-data /app/backend \ && find /app/backend -type d -exec chmod 755 {} \; \ && find /app/backend -type f -exec chmod 644 {} \; \ - && chmod -R 777 /app/backend/storage /app/backend/bootstrap/cache \ + && chmod -R 755 /app/backend/storage /app/backend/bootstrap/cache \ && composer install --working-dir=/app/backend \ --ignore-platform-reqs \ --no-interaction \ --no-dev \ --optimize-autoloader \ --prefer-dist \ - && chmod -R 777 /app/backend/vendor/ezyang/htmlpurifier/library/HTMLPurifier/DefinitionCache/Serializer + && chmod -R 755 /app/backend/vendor/ezyang/htmlpurifier/library/HTMLPurifier/DefinitionCache/Serializer COPY ./docker/all-in-one/nginx/nginx.conf /etc/nginx/nginx.conf COPY ./docker/all-in-one/supervisor/supervisord.conf /etc/supervisord.conf diff --git a/README.md b/README.md index 4b511adef0..c6e5a54090 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,10 @@ Hi.Events Logo

-

Hi.Events

-Demo Event 🌟Website 🌎Documentation 📄Installation ⚙️ +Demo Event 🌟Website 🌎Documentation 📄Installation ⚙️

@@ -59,34 +58,55 @@ Hi.Events is a feature-rich, self-hosted event management and ticketing platform. From conferences to club nights, Hi.Events is designed to help you create, manage, and sell tickets for events of all sizes. -Hi.Events self-hosted ticket selling dashboard +Hi.Events self-hosted ticket selling dashboard +Generated using Screenshot Rocks ## 🌟 Features Hi.Events is packed with features to streamline your event management and ticketing: -- 📊 **Event Analytics:** Gain deep insights into event performance and ticket sales. -- 🎟 **Embeddable Ticket Widget:** Easily integrate ticket sales into any website. -- 🖥 **Customizable Event Homepages:** Create eye-catching event pages with flexible design options. -- 🔑 **Intuitive Check-In Tools:** Easily check in attendees at the door with Hi.Events' QR code check-in tool. -- 💬 **Event Messaging Tools:** Message attendees with important updates and reminders. -- 📝 **Custom Order Forms:** Collect attendee information with tailored questions at checkout. -- 🎫 **Multiple Ticket Types:** Free, paid, donation, or tiered ticket types. -- 💸 **Versatile Promo Codes:** Highly versatile discount codes. Pre-sale access, multiple discount options. -- 💰 **Instant Payouts:** Enjoy instant payouts with seamless Stripe integration. -- 🧾 **Tax and Fee Configuration:** Add tax and fees on a per-ticket basis. -- 📦 **Data Exports:** Export attendee and order data to XLSX or CSV. -- 💻 **REST API:** Full-featured REST API for custom integrations. -- 🔍 **SEO Tools:** Customize SEO settings for each event. -- 🛒 **Beautiful Checkout Process:** Ensure a smooth, beautiful checkout experience. -- 🔐 **Role-Based Access:** Support for multiple user roles. -- 💻 **Online Event Support:** Offer online event instructions and links. -- ⏪ **Full and Partial Refund Support:** Manage full and partial refunds with ease. -- 📧 **Email Notifications:** Keep attendees informed with automated email notifications. -- 📱 **Mobile-Responsive:** Enjoy a seamless experience on any device. -- 🌐 **Multi-Language Support:** Support for multiple languages (English, Português, Español, 中文 (Zhōngwén), Deutsch, Français) -- 🔋 **Advanced Capacity Management:** Set capacity limits across multiple ticket types. -- 🎉 **And much more!** +### 🎟 Ticketing & Product Sales +- **Multiple Ticket Types:** Free, Paid, Donation, and Tiered tickets. +- **Capacity Management:** Set event-wide or ticket-specific limits. +- **Capacity Assignments:** Manage shared capacity across multiple ticket types. +- **Promo Codes:** Discount codes for pre-sale access and special offers. +- **Product Sales:** Sell event-related products (e.g., t-shirts, add-ons). +- **Taxes & Fees:** Apply custom taxes and fees per product or order. + +### 🏆 Event Management & Customization +- **Event Dashboard:** Real-time revenue, ticket sales, and attendee analytics. +- **Homepage Designer:** Customize event pages with a live preview editor. +- **Embeddable Ticket Widget:** Add a seamless ticketing experience to your website. +- **SEO Tools:** Customize event metadata for better search visibility. +- **Product Categories:** Organize products and tickets with category management. +- **Offline Event Support:** Provide instructions for physical events. + +### 📧 Attendee & Order Management +- **Custom Checkout Forms:** Collect attendee details with tailored questions. +- **Attendee Management:** Search, edit, cancel, and message attendees. +- **Order Management:** Refund, cancel, and resend order details easily. +- **Bulk Messaging:** Email or message specific ticket holders. +- **Data Exports:** Export attendees and orders to CSV/XLSX. + +### 📱 Mobile-Friendly & Check-In Tools +- **QR Code Check-In:** Web-based and mobile-friendly check-in tool. +- **Check-In Lists:** Generate and share access-controlled check-in lists. +- **Multi-User Access:** Role-based access control for event staff. + +### 🔧 Integrations & Automation +- **Webhooks Support:** Automate tasks with Zapier, IFTTT, Make, or CRM integrations. +- **Stripe Connect Integration:** Organizers get instant payouts. + +### 📊 Advanced Features +- **Multi-Language Support:** English, Deutsch, Español, Português, Français, 中文 (Zhōngwén), and more. +- **Partial & Full Refunds:** Manage refunds with detailed order tracking. +- **Role-Based Access Control:** Multiple user roles with permission management. +- **REST API:** Full API access for custom integrations. +- **Invoicing System:** Generate and send invoices with tax details, payment terms, and due dates. +- **Offline Payment Support:** Enable bank transfers, cash payments, or custom payment methods. +- **Event Archive:** Archive past events to keep the dashboard organized. +- **Advanced Ticket Locking:** Lock tickets behind promo codes or access restrictions. +- **Advanced Reporting:** Daily sales, tax breakdowns, product sales, and promo code usage reports. ## 🚀 Getting Started diff --git a/backend/.env.example b/backend/.env.example index 1ebe5d5ccd..974d9be584 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -11,12 +11,14 @@ APP_SAAS_MODE_ENABLED=false APP_SAAS_STRIPE_APPLICATION_FEE_PERCENT=1.5 APP_HOMEPAGE_VIEWS_UPDATE_BATCH_SIZE=8 APP_DISABLE_REGISTRATION=false +APP_STRIPE_CONNECT_ACCOUNT_TYPE=express +APP_PLATFORM_SUPPORT_EMAIL=support@your-website.com STRIPE_PUBLIC_KEY= STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= -CORS_ALLOWED_ORIGINS=http://localhost:5173 +CORS_ALLOWED_ORIGINS=* LOG_CHANNEL=stderr LOG_DEPRECATIONS_CHANNEL=null diff --git a/backend/.gitignore b/backend/.gitignore index 67bc1bd0f2..e2d08b3157 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -8,6 +8,7 @@ .env .env.backup .env.production +.env.staging .phpunit.result.cache Homestead.json Homestead.yaml @@ -19,3 +20,4 @@ yarn-error.log /.vscode .idea /app-back +bootstrap/cache diff --git a/backend/app/DomainObjects/AccountConfigurationDomainObject.php b/backend/app/DomainObjects/AccountConfigurationDomainObject.php new file mode 100644 index 0000000000..c515968a6b --- /dev/null +++ b/backend/app/DomainObjects/AccountConfigurationDomainObject.php @@ -0,0 +1,16 @@ +getApplicationFees()['fixed'] ?? config('app.default_application_fee_fixed'); + } + + public function getPercentageApplicationFee(): float + { + return $this->getApplicationFees()['percentage'] ?? config('app.default_application_fee_percentage'); + } +} diff --git a/backend/app/DomainObjects/AccountDomainObject.php b/backend/app/DomainObjects/AccountDomainObject.php index 3591c1281c..33cdb98285 100644 --- a/backend/app/DomainObjects/AccountDomainObject.php +++ b/backend/app/DomainObjects/AccountDomainObject.php @@ -2,6 +2,30 @@ namespace HiEvents\DomainObjects; +use HiEvents\DomainObjects\DTO\AccountApplicationFeeDTO; + class AccountDomainObject extends Generated\AccountDomainObjectAbstract { + private ?AccountConfigurationDomainObject $configuration = 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; + } } diff --git a/backend/app/DomainObjects/ApplicationFeeDomainObject.php b/backend/app/DomainObjects/ApplicationFeeDomainObject.php new file mode 100644 index 0000000000..3c374434c4 --- /dev/null +++ b/backend/app/DomainObjects/ApplicationFeeDomainObject.php @@ -0,0 +1,7 @@ +|null */ public ?Collection $questionAndAnswerViews = null; @@ -29,7 +29,7 @@ public static function getAllowedSorts(): AllowedSorts [ self::CREATED_AT => [ 'asc' => __('Older First'), - 'desc' => __('Newer First'), + 'desc' => __('Newest First'), ], self::UPDATED_AT => [ 'desc' => __('Recently Updated First'), @@ -60,7 +60,7 @@ public static function getAllowedFilterFields(): array { return [ self::STATUS, - self::TICKET_ID, + self::PRODUCT_ID, ]; } @@ -79,14 +79,14 @@ public function getFullName(): string return $this->first_name . ' ' . $this->last_name; } - public function getTicket(): ?TicketDomainObject + public function getProduct(): ?ProductDomainObject { - return $this->ticket; + return $this->product; } - public function setTicket(?TicketDomainObject $ticket): self + public function setProduct(?ProductDomainObject $product): self { - $this->ticket = $ticket; + $this->product = $product; return $this; } diff --git a/backend/app/DomainObjects/CapacityAssignmentDomainObject.php b/backend/app/DomainObjects/CapacityAssignmentDomainObject.php index 758847d251..642fadae10 100644 --- a/backend/app/DomainObjects/CapacityAssignmentDomainObject.php +++ b/backend/app/DomainObjects/CapacityAssignmentDomainObject.php @@ -9,7 +9,7 @@ class CapacityAssignmentDomainObject extends Generated\CapacityAssignmentDomainObjectAbstract implements IsSortable { - public ?Collection $tickets = null; + public ?Collection $products = null; public static function getDefaultSort(): string { @@ -58,14 +58,14 @@ public function getPercentageUsed(): float return round(($this->getUsedCapacity() / $this->getCapacity()) * 100, 2); } - public function getTickets(): ?Collection + public function getProducts(): ?Collection { - return $this->tickets; + return $this->products; } - public function setTickets(?Collection $tickets): static + public function setProducts(?Collection $products): static { - $this->tickets = $tickets; + $this->products = $products; return $this; } diff --git a/backend/app/DomainObjects/CheckInListDomainObject.php b/backend/app/DomainObjects/CheckInListDomainObject.php index 30364989e6..ae55f3bbcf 100644 --- a/backend/app/DomainObjects/CheckInListDomainObject.php +++ b/backend/app/DomainObjects/CheckInListDomainObject.php @@ -9,7 +9,7 @@ class CheckInListDomainObject extends Generated\CheckInListDomainObjectAbstract implements IsSortable { - private ?Collection $tickets = null; + private ?Collection $products = null; private ?EventDomainObject $event = null; @@ -53,14 +53,14 @@ public static function getAllowedSorts(): AllowedSorts ); } - public function getTickets(): ?Collection + public function getProducts(): ?Collection { - return $this->tickets; + return $this->products; } - public function setTickets(?Collection $tickets): static + public function setProducts(?Collection $products): static { - $this->tickets = $tickets; + $this->products = $products; return $this; } diff --git a/backend/app/DomainObjects/DTO/AccountApplicationFeeDTO.php b/backend/app/DomainObjects/DTO/AccountApplicationFeeDTO.php new file mode 100644 index 0000000000..2c36832e00 --- /dev/null +++ b/backend/app/DomainObjects/DTO/AccountApplicationFeeDTO.php @@ -0,0 +1,13 @@ +tickets = $tickets; + $this->products = $products; return $this; } - public function getTickets(): ?Collection + public function getProducts(): ?Collection { - return $this->tickets; + return $this->products; } public function setQuestions(?Collection $questions): EventDomainObject @@ -141,6 +147,17 @@ public function setOrganizer(?OrganizerDomainObject $organizer): self return $this; } + public function getAccount(): ?AccountDomainObject + { + return $this->account; + } + + public function setAccount(?AccountDomainObject $account): self + { + $this->account = $account; + return $this; + } + public function getEventUrl(): string { return sprintf( @@ -259,4 +276,26 @@ public function setEventStatistics(?EventStatisticDomainObject $eventStatistics) $this->eventStatistics = $eventStatistics; return $this; } + + public function setProductCategories(?Collection $productCategories): EventDomainObject + { + $this->productCategories = $productCategories; + return $this; + } + + public function getProductCategories(): ?Collection + { + return $this->productCategories; + } + + public function getWebhooks(): ?Collection + { + return $this->webhooks; + } + + public function setWebhooks(?Collection $webhooks): EventDomainObject + { + $this->webhooks = $webhooks; + return $this; + } } diff --git a/backend/app/DomainObjects/EventSettingDomainObject.php b/backend/app/DomainObjects/EventSettingDomainObject.php index d92eb69b69..baae62174d 100644 --- a/backend/app/DomainObjects/EventSettingDomainObject.php +++ b/backend/app/DomainObjects/EventSettingDomainObject.php @@ -3,6 +3,7 @@ namespace HiEvents\DomainObjects; use HiEvents\DataTransferObjects\AddressDTO; +use HiEvents\Helper\AddressHelper; class EventSettingDomainObject extends Generated\EventSettingDomainObjectAbstract { @@ -25,25 +26,7 @@ public function getGetEmailFooterHtml(): string public function getAddressString(): string { - $locationDetails = $this->getLocationDetails(); - - if (is_null($locationDetails)) { - return ''; - } - - $addressParts = [ - $locationDetails['venue_name'] ?? null, - $locationDetails['address_line_1'] ?? null, - $locationDetails['address_line_2'] ?? null, - $locationDetails['city'] ?? null, - $locationDetails['state_or_region'] ?? null, - $locationDetails['zip_or_postal_code'] ?? null, - $locationDetails['country'] ?? null - ]; - - $filteredAddressParts = array_filter($addressParts, static fn($part) => !is_null($part) && $part !== ''); - - return implode(', ', $filteredAddressParts); + return AddressHelper::formatAddress($this->getLocationDetails()); } public function getAddress(): AddressDTO diff --git a/backend/app/DomainObjects/Generated/AccountConfigurationDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/AccountConfigurationDomainObjectAbstract.php new file mode 100644 index 0000000000..257f09c0a4 --- /dev/null +++ b/backend/app/DomainObjects/Generated/AccountConfigurationDomainObjectAbstract.php @@ -0,0 +1,118 @@ + $this->id ?? null, + 'name' => $this->name ?? null, + 'is_system_default' => $this->is_system_default ?? null, + 'application_fees' => $this->application_fees ?? 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 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/AccountDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/AccountDomainObjectAbstract.php index fcf82ef683..0c3a27fbf0 100644 --- a/backend/app/DomainObjects/Generated/AccountDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/AccountDomainObjectAbstract.php @@ -11,6 +11,7 @@ abstract class AccountDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr final public const SINGULAR_NAME = 'account'; final public const PLURAL_NAME = 'accounts'; final public const ID = 'id'; + final public const ACCOUNT_CONFIGURATION_ID = 'account_configuration_id'; final public const CURRENCY_CODE = 'currency_code'; final public const TIMEZONE = 'timezone'; final public const CREATED_AT = 'created_at'; @@ -22,8 +23,11 @@ abstract class AccountDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr final public const SHORT_ID = 'short_id'; final public const STRIPE_CONNECT_SETUP_COMPLETE = 'stripe_connect_setup_complete'; final public const ACCOUNT_VERIFIED_AT = 'account_verified_at'; + final public const STRIPE_CONNECT_ACCOUNT_TYPE = 'stripe_connect_account_type'; + final public const IS_MANUALLY_VERIFIED = 'is_manually_verified'; protected int $id; + protected ?int $account_configuration_id = null; protected string $currency_code = 'USD'; protected ?string $timezone = null; protected ?string $created_at = null; @@ -35,11 +39,14 @@ abstract class AccountDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr protected string $short_id; protected ?bool $stripe_connect_setup_complete = false; protected ?string $account_verified_at = null; + protected ?string $stripe_connect_account_type = null; + protected bool $is_manually_verified = false; public function toArray(): array { return [ 'id' => $this->id ?? null, + 'account_configuration_id' => $this->account_configuration_id ?? null, 'currency_code' => $this->currency_code ?? null, 'timezone' => $this->timezone ?? null, 'created_at' => $this->created_at ?? null, @@ -51,6 +58,8 @@ public function toArray(): array 'short_id' => $this->short_id ?? null, 'stripe_connect_setup_complete' => $this->stripe_connect_setup_complete ?? null, 'account_verified_at' => $this->account_verified_at ?? null, + 'stripe_connect_account_type' => $this->stripe_connect_account_type ?? null, + 'is_manually_verified' => $this->is_manually_verified ?? null, ]; } @@ -65,6 +74,17 @@ public function getId(): int return $this->id; } + public function setAccountConfigurationId(?int $account_configuration_id): self + { + $this->account_configuration_id = $account_configuration_id; + return $this; + } + + public function getAccountConfigurationId(): ?int + { + return $this->account_configuration_id; + } + public function setCurrencyCode(string $currency_code): self { $this->currency_code = $currency_code; @@ -185,4 +205,26 @@ public function getAccountVerifiedAt(): ?string { return $this->account_verified_at; } + + 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 setIsManuallyVerified(bool $is_manually_verified): self + { + $this->is_manually_verified = $is_manually_verified; + return $this; + } + + public function getIsManuallyVerified(): bool + { + return $this->is_manually_verified; + } } diff --git a/backend/app/DomainObjects/Generated/ApplicationFeeDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/ApplicationFeeDomainObjectAbstract.php new file mode 100644 index 0000000000..0c3812a6cb --- /dev/null +++ b/backend/app/DomainObjects/Generated/ApplicationFeeDomainObjectAbstract.php @@ -0,0 +1,174 @@ + $this->id ?? null, + 'order_id' => $this->order_id ?? null, + 'amount' => $this->amount ?? null, + 'currency' => $this->currency ?? null, + 'status' => $this->status ?? null, + 'payment_method' => $this->payment_method ?? null, + 'metadata' => $this->metadata ?? null, + 'paid_at' => $this->paid_at ?? 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 setOrderId(int $order_id): self + { + $this->order_id = $order_id; + return $this; + } + + public function getOrderId(): int + { + return $this->order_id; + } + + public function setAmount(float $amount): self + { + $this->amount = $amount; + return $this; + } + + public function getAmount(): float + { + return $this->amount; + } + + public function setCurrency(string $currency): self + { + $this->currency = $currency; + return $this; + } + + public function getCurrency(): string + { + return $this->currency; + } + + public function setStatus(string $status): self + { + $this->status = $status; + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setPaymentMethod(string $payment_method): self + { + $this->payment_method = $payment_method; + return $this; + } + + public function getPaymentMethod(): string + { + return $this->payment_method; + } + + public function setMetadata(array|string|null $metadata): self + { + $this->metadata = $metadata; + return $this; + } + + public function getMetadata(): array|string|null + { + return $this->metadata; + } + + public function setPaidAt(?string $paid_at): self + { + $this->paid_at = $paid_at; + return $this; + } + + public function getPaidAt(): ?string + { + return $this->paid_at; + } + + 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/AttendeeCheckInDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/AttendeeCheckInDomainObjectAbstract.php index ffb56a8d57..1249d2714c 100644 --- a/backend/app/DomainObjects/Generated/AttendeeCheckInDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/AttendeeCheckInDomainObjectAbstract.php @@ -12,7 +12,7 @@ abstract class AttendeeCheckInDomainObjectAbstract extends \HiEvents\DomainObjec final public const PLURAL_NAME = 'attendee_check_ins'; final public const ID = 'id'; final public const CHECK_IN_LIST_ID = 'check_in_list_id'; - final public const TICKET_ID = 'ticket_id'; + final public const PRODUCT_ID = 'product_id'; final public const ATTENDEE_ID = 'attendee_id'; final public const EVENT_ID = 'event_id'; final public const SHORT_ID = 'short_id'; @@ -23,7 +23,7 @@ abstract class AttendeeCheckInDomainObjectAbstract extends \HiEvents\DomainObjec protected int $id; protected int $check_in_list_id; - protected int $ticket_id; + protected int $product_id; protected int $attendee_id; protected int $event_id; protected string $short_id; @@ -37,7 +37,7 @@ public function toArray(): array return [ 'id' => $this->id ?? null, 'check_in_list_id' => $this->check_in_list_id ?? null, - 'ticket_id' => $this->ticket_id ?? null, + 'product_id' => $this->product_id ?? null, 'attendee_id' => $this->attendee_id ?? null, 'event_id' => $this->event_id ?? null, 'short_id' => $this->short_id ?? null, @@ -70,15 +70,15 @@ public function getCheckInListId(): int return $this->check_in_list_id; } - public function setTicketId(int $ticket_id): self + public function setProductId(int $product_id): self { - $this->ticket_id = $ticket_id; + $this->product_id = $product_id; return $this; } - public function getTicketId(): int + public function getProductId(): int { - return $this->ticket_id; + return $this->product_id; } public function setAttendeeId(int $attendee_id): self diff --git a/backend/app/DomainObjects/Generated/AttendeeDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/AttendeeDomainObjectAbstract.php index 59b052de07..be3ca97e0e 100644 --- a/backend/app/DomainObjects/Generated/AttendeeDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/AttendeeDomainObjectAbstract.php @@ -12,11 +12,11 @@ abstract class AttendeeDomainObjectAbstract extends \HiEvents\DomainObjects\Abst final public const PLURAL_NAME = 'attendees'; final public const ID = 'id'; final public const ORDER_ID = 'order_id'; - final public const TICKET_ID = 'ticket_id'; + final public const PRODUCT_ID = 'product_id'; final public const EVENT_ID = 'event_id'; final public const CHECKED_IN_BY = 'checked_in_by'; final public const CHECKED_OUT_BY = 'checked_out_by'; - final public const TICKET_PRICE_ID = 'ticket_price_id'; + final public const PRODUCT_PRICE_ID = 'product_price_id'; final public const SHORT_ID = 'short_id'; final public const FIRST_NAME = 'first_name'; final public const LAST_NAME = 'last_name'; @@ -28,14 +28,15 @@ abstract class AttendeeDomainObjectAbstract extends \HiEvents\DomainObjects\Abst final public const UPDATED_AT = 'updated_at'; final public const DELETED_AT = 'deleted_at'; final public const LOCALE = 'locale'; + final public const NOTES = 'notes'; protected int $id; protected int $order_id; - protected int $ticket_id; + protected int $product_id; protected int $event_id; protected ?int $checked_in_by = null; protected ?int $checked_out_by = null; - protected int $ticket_price_id; + protected int $product_price_id; protected string $short_id; protected string $first_name = ''; protected string $last_name = ''; @@ -47,17 +48,18 @@ abstract class AttendeeDomainObjectAbstract extends \HiEvents\DomainObjects\Abst protected string $updated_at; protected ?string $deleted_at = null; protected string $locale = 'en'; + protected ?string $notes = null; public function toArray(): array { return [ 'id' => $this->id ?? null, 'order_id' => $this->order_id ?? null, - 'ticket_id' => $this->ticket_id ?? null, + 'product_id' => $this->product_id ?? null, 'event_id' => $this->event_id ?? null, 'checked_in_by' => $this->checked_in_by ?? null, 'checked_out_by' => $this->checked_out_by ?? null, - 'ticket_price_id' => $this->ticket_price_id ?? null, + 'product_price_id' => $this->product_price_id ?? null, 'short_id' => $this->short_id ?? null, 'first_name' => $this->first_name ?? null, 'last_name' => $this->last_name ?? null, @@ -69,6 +71,7 @@ public function toArray(): array 'updated_at' => $this->updated_at ?? null, 'deleted_at' => $this->deleted_at ?? null, 'locale' => $this->locale ?? null, + 'notes' => $this->notes ?? null, ]; } @@ -94,15 +97,15 @@ public function getOrderId(): int return $this->order_id; } - public function setTicketId(int $ticket_id): self + public function setProductId(int $product_id): self { - $this->ticket_id = $ticket_id; + $this->product_id = $product_id; return $this; } - public function getTicketId(): int + public function getProductId(): int { - return $this->ticket_id; + return $this->product_id; } public function setEventId(int $event_id): self @@ -138,15 +141,15 @@ public function getCheckedOutBy(): ?int return $this->checked_out_by; } - public function setTicketPriceId(int $ticket_price_id): self + public function setProductPriceId(int $product_price_id): self { - $this->ticket_price_id = $ticket_price_id; + $this->product_price_id = $product_price_id; return $this; } - public function getTicketPriceId(): int + public function getProductPriceId(): int { - return $this->ticket_price_id; + return $this->product_price_id; } public function setShortId(string $short_id): self @@ -269,4 +272,15 @@ public function getLocale(): string { return $this->locale; } + + public function setNotes(?string $notes): self + { + $this->notes = $notes; + return $this; + } + + public function getNotes(): ?string + { + return $this->notes; + } } diff --git a/backend/app/DomainObjects/Generated/EventDailyStatisticDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventDailyStatisticDomainObjectAbstract.php index beae7bfa03..ac1749cb9c 100644 --- a/backend/app/DomainObjects/Generated/EventDailyStatisticDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventDailyStatisticDomainObjectAbstract.php @@ -15,7 +15,7 @@ abstract class EventDailyStatisticDomainObjectAbstract extends \HiEvents\DomainO final public const SALES_TOTAL_GROSS = 'sales_total_gross'; final public const TOTAL_TAX = 'total_tax'; final public const SALES_TOTAL_BEFORE_ADDITIONS = 'sales_total_before_additions'; - final public const TICKETS_SOLD = 'tickets_sold'; + final public const PRODUCTS_SOLD = 'products_sold'; final public const ORDERS_CREATED = 'orders_created'; final public const DATE = 'date'; final public const CREATED_AT = 'created_at'; @@ -25,13 +25,14 @@ abstract class EventDailyStatisticDomainObjectAbstract extends \HiEvents\DomainO final public const VERSION = 'version'; final public const TOTAL_REFUNDED = 'total_refunded'; final public const TOTAL_VIEWS = 'total_views'; + final public const ATTENDEES_REGISTERED = 'attendees_registered'; protected int $id; protected int $event_id; protected float $sales_total_gross = 0.0; protected float $total_tax = 0.0; protected float $sales_total_before_additions = 0.0; - protected int $tickets_sold = 0; + protected int $products_sold = 0; protected int $orders_created = 0; protected string $date; protected string $created_at; @@ -41,6 +42,7 @@ abstract class EventDailyStatisticDomainObjectAbstract extends \HiEvents\DomainO protected int $version = 0; protected float $total_refunded = 0.0; protected int $total_views = 0; + protected int $attendees_registered = 0; public function toArray(): array { @@ -50,7 +52,7 @@ public function toArray(): array 'sales_total_gross' => $this->sales_total_gross ?? null, 'total_tax' => $this->total_tax ?? null, 'sales_total_before_additions' => $this->sales_total_before_additions ?? null, - 'tickets_sold' => $this->tickets_sold ?? null, + 'products_sold' => $this->products_sold ?? null, 'orders_created' => $this->orders_created ?? null, 'date' => $this->date ?? null, 'created_at' => $this->created_at ?? null, @@ -60,6 +62,7 @@ public function toArray(): array 'version' => $this->version ?? null, 'total_refunded' => $this->total_refunded ?? null, 'total_views' => $this->total_views ?? null, + 'attendees_registered' => $this->attendees_registered ?? null, ]; } @@ -118,15 +121,15 @@ public function getSalesTotalBeforeAdditions(): float return $this->sales_total_before_additions; } - public function setTicketsSold(int $tickets_sold): self + public function setProductsSold(int $products_sold): self { - $this->tickets_sold = $tickets_sold; + $this->products_sold = $products_sold; return $this; } - public function getTicketsSold(): int + public function getProductsSold(): int { - return $this->tickets_sold; + return $this->products_sold; } public function setOrdersCreated(int $orders_created): self @@ -227,4 +230,15 @@ public function getTotalViews(): int { return $this->total_views; } + + public function setAttendeesRegistered(int $attendees_registered): self + { + $this->attendees_registered = $attendees_registered; + return $this; + } + + public function getAttendeesRegistered(): int + { + return $this->attendees_registered; + } } diff --git a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php index a883a7e23b..ed4088fd01 100644 --- a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php @@ -14,7 +14,7 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ final public const EVENT_ID = 'event_id'; final public const PRE_CHECKOUT_MESSAGE = 'pre_checkout_message'; final public const POST_CHECKOUT_MESSAGE = 'post_checkout_message'; - final public const TICKET_PAGE_MESSAGE = 'ticket_page_message'; + final public const PRODUCT_PAGE_MESSAGE = 'product_page_message'; final public const CONTINUE_BUTTON_TEXT = 'continue_button_text'; final public const EMAIL_FOOTER_MESSAGE = 'email_footer_message'; final public const SUPPORT_EMAIL = 'support_email'; @@ -45,12 +45,25 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ final public const SHOW_SHARE_BUTTONS = 'show_share_buttons'; final public const HOMEPAGE_BODY_BACKGROUND_COLOR = 'homepage_body_background_color'; final public const HOMEPAGE_BACKGROUND_TYPE = 'homepage_background_type'; + final public const ENABLE_INVOICING = 'enable_invoicing'; + final public const INVOICE_LABEL = 'invoice_label'; + final public const INVOICE_PREFIX = 'invoice_prefix'; + final public const INVOICE_START_NUMBER = 'invoice_start_number'; + final public const REQUIRE_BILLING_ADDRESS = 'require_billing_address'; + final public const ORGANIZATION_NAME = 'organization_name'; + final public const ORGANIZATION_ADDRESS = 'organization_address'; + final public const INVOICE_TAX_DETAILS = 'invoice_tax_details'; + final public const PAYMENT_PROVIDERS = 'payment_providers'; + final public const OFFLINE_PAYMENT_INSTRUCTIONS = 'offline_payment_instructions'; + final public const ALLOW_ORDERS_AWAITING_OFFLINE_PAYMENT_TO_CHECK_IN = 'allow_orders_awaiting_offline_payment_to_check_in'; + final public const INVOICE_PAYMENT_TERMS_DAYS = 'invoice_payment_terms_days'; + final public const INVOICE_NOTES = 'invoice_notes'; protected int $id; protected int $event_id; protected ?string $pre_checkout_message = null; protected ?string $post_checkout_message = null; - protected ?string $ticket_page_message = null; + protected ?string $product_page_message = null; protected ?string $continue_button_text = null; protected ?string $email_footer_message = null; protected ?string $support_email = null; @@ -81,6 +94,19 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ protected bool $show_share_buttons = true; protected ?string $homepage_body_background_color = null; protected string $homepage_background_type = 'COLOR'; + protected bool $enable_invoicing = false; + protected ?string $invoice_label = null; + protected ?string $invoice_prefix = null; + protected int $invoice_start_number = 1; + protected bool $require_billing_address = true; + protected ?string $organization_name = null; + protected ?string $organization_address = null; + protected ?string $invoice_tax_details = null; + protected array|string|null $payment_providers = null; + protected ?string $offline_payment_instructions = null; + protected bool $allow_orders_awaiting_offline_payment_to_check_in = false; + protected ?int $invoice_payment_terms_days = null; + protected ?string $invoice_notes = null; public function toArray(): array { @@ -89,7 +115,7 @@ public function toArray(): array 'event_id' => $this->event_id ?? null, 'pre_checkout_message' => $this->pre_checkout_message ?? null, 'post_checkout_message' => $this->post_checkout_message ?? null, - 'ticket_page_message' => $this->ticket_page_message ?? null, + 'product_page_message' => $this->product_page_message ?? null, 'continue_button_text' => $this->continue_button_text ?? null, 'email_footer_message' => $this->email_footer_message ?? null, 'support_email' => $this->support_email ?? null, @@ -120,6 +146,19 @@ public function toArray(): array 'show_share_buttons' => $this->show_share_buttons ?? null, 'homepage_body_background_color' => $this->homepage_body_background_color ?? null, 'homepage_background_type' => $this->homepage_background_type ?? null, + 'enable_invoicing' => $this->enable_invoicing ?? null, + 'invoice_label' => $this->invoice_label ?? null, + 'invoice_prefix' => $this->invoice_prefix ?? null, + 'invoice_start_number' => $this->invoice_start_number ?? null, + 'require_billing_address' => $this->require_billing_address ?? null, + 'organization_name' => $this->organization_name ?? null, + 'organization_address' => $this->organization_address ?? null, + 'invoice_tax_details' => $this->invoice_tax_details ?? null, + 'payment_providers' => $this->payment_providers ?? null, + 'offline_payment_instructions' => $this->offline_payment_instructions ?? null, + 'allow_orders_awaiting_offline_payment_to_check_in' => $this->allow_orders_awaiting_offline_payment_to_check_in ?? null, + 'invoice_payment_terms_days' => $this->invoice_payment_terms_days ?? null, + 'invoice_notes' => $this->invoice_notes ?? null, ]; } @@ -167,15 +206,15 @@ public function getPostCheckoutMessage(): ?string return $this->post_checkout_message; } - public function setTicketPageMessage(?string $ticket_page_message): self + public function setProductPageMessage(?string $product_page_message): self { - $this->ticket_page_message = $ticket_page_message; + $this->product_page_message = $product_page_message; return $this; } - public function getTicketPageMessage(): ?string + public function getProductPageMessage(): ?string { - return $this->ticket_page_message; + return $this->product_page_message; } public function setContinueButtonText(?string $continue_button_text): self @@ -507,4 +546,148 @@ public function getHomepageBackgroundType(): string { return $this->homepage_background_type; } + + public function setEnableInvoicing(bool $enable_invoicing): self + { + $this->enable_invoicing = $enable_invoicing; + return $this; + } + + public function getEnableInvoicing(): bool + { + return $this->enable_invoicing; + } + + public function setInvoiceLabel(?string $invoice_label): self + { + $this->invoice_label = $invoice_label; + return $this; + } + + public function getInvoiceLabel(): ?string + { + return $this->invoice_label; + } + + public function setInvoicePrefix(?string $invoice_prefix): self + { + $this->invoice_prefix = $invoice_prefix; + return $this; + } + + public function getInvoicePrefix(): ?string + { + return $this->invoice_prefix; + } + + public function setInvoiceStartNumber(int $invoice_start_number): self + { + $this->invoice_start_number = $invoice_start_number; + return $this; + } + + public function getInvoiceStartNumber(): int + { + return $this->invoice_start_number; + } + + public function setRequireBillingAddress(bool $require_billing_address): self + { + $this->require_billing_address = $require_billing_address; + return $this; + } + + public function getRequireBillingAddress(): bool + { + return $this->require_billing_address; + } + + public function setOrganizationName(?string $organization_name): self + { + $this->organization_name = $organization_name; + return $this; + } + + public function getOrganizationName(): ?string + { + return $this->organization_name; + } + + public function setOrganizationAddress(?string $organization_address): self + { + $this->organization_address = $organization_address; + return $this; + } + + public function getOrganizationAddress(): ?string + { + return $this->organization_address; + } + + public function setInvoiceTaxDetails(?string $invoice_tax_details): self + { + $this->invoice_tax_details = $invoice_tax_details; + return $this; + } + + public function getInvoiceTaxDetails(): ?string + { + return $this->invoice_tax_details; + } + + public function setPaymentProviders(array|string|null $payment_providers): self + { + $this->payment_providers = $payment_providers; + return $this; + } + + public function getPaymentProviders(): array|string|null + { + return $this->payment_providers; + } + + public function setOfflinePaymentInstructions(?string $offline_payment_instructions): self + { + $this->offline_payment_instructions = $offline_payment_instructions; + return $this; + } + + public function getOfflinePaymentInstructions(): ?string + { + return $this->offline_payment_instructions; + } + + public function setAllowOrdersAwaitingOfflinePaymentToCheckIn( + bool $allow_orders_awaiting_offline_payment_to_check_in, + ): self { + $this->allow_orders_awaiting_offline_payment_to_check_in = $allow_orders_awaiting_offline_payment_to_check_in; + return $this; + } + + public function getAllowOrdersAwaitingOfflinePaymentToCheckIn(): bool + { + return $this->allow_orders_awaiting_offline_payment_to_check_in; + } + + public function setInvoicePaymentTermsDays(?int $invoice_payment_terms_days): self + { + $this->invoice_payment_terms_days = $invoice_payment_terms_days; + return $this; + } + + public function getInvoicePaymentTermsDays(): ?int + { + return $this->invoice_payment_terms_days; + } + + public function setInvoiceNotes(?string $invoice_notes): self + { + $this->invoice_notes = $invoice_notes; + return $this; + } + + public function getInvoiceNotes(): ?string + { + return $this->invoice_notes; + } } diff --git a/backend/app/DomainObjects/Generated/EventStatisticDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventStatisticDomainObjectAbstract.php index 1519b8fbe9..e92c464041 100644 --- a/backend/app/DomainObjects/Generated/EventStatisticDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventStatisticDomainObjectAbstract.php @@ -21,10 +21,11 @@ abstract class EventStatisticDomainObjectAbstract extends \HiEvents\DomainObject final public const DELETED_AT = 'deleted_at'; final public const UPDATED_AT = 'updated_at'; final public const TOTAL_FEE = 'total_fee'; - final public const TICKETS_SOLD = 'tickets_sold'; + final public const PRODUCTS_SOLD = 'products_sold'; final public const VERSION = 'version'; final public const ORDERS_CREATED = 'orders_created'; final public const TOTAL_REFUNDED = 'total_refunded'; + final public const ATTENDEES_REGISTERED = 'attendees_registered'; protected int $id; protected int $event_id; @@ -37,10 +38,11 @@ abstract class EventStatisticDomainObjectAbstract extends \HiEvents\DomainObject protected ?string $deleted_at = null; protected ?string $updated_at = null; protected float $total_fee = 0.0; - protected int $tickets_sold = 0; + protected int $products_sold = 0; protected int $version = 0; protected int $orders_created = 0; protected float $total_refunded = 0.0; + protected int $attendees_registered = 0; public function toArray(): array { @@ -56,10 +58,11 @@ public function toArray(): array 'deleted_at' => $this->deleted_at ?? null, 'updated_at' => $this->updated_at ?? null, 'total_fee' => $this->total_fee ?? null, - 'tickets_sold' => $this->tickets_sold ?? null, + 'products_sold' => $this->products_sold ?? null, 'version' => $this->version ?? null, 'orders_created' => $this->orders_created ?? null, 'total_refunded' => $this->total_refunded ?? null, + 'attendees_registered' => $this->attendees_registered ?? null, ]; } @@ -184,15 +187,15 @@ public function getTotalFee(): float return $this->total_fee; } - public function setTicketsSold(int $tickets_sold): self + public function setProductsSold(int $products_sold): self { - $this->tickets_sold = $tickets_sold; + $this->products_sold = $products_sold; return $this; } - public function getTicketsSold(): int + public function getProductsSold(): int { - return $this->tickets_sold; + return $this->products_sold; } public function setVersion(int $version): self @@ -227,4 +230,15 @@ public function getTotalRefunded(): float { return $this->total_refunded; } + + public function setAttendeesRegistered(int $attendees_registered): self + { + $this->attendees_registered = $attendees_registered; + return $this; + } + + public function getAttendeesRegistered(): int + { + return $this->attendees_registered; + } } diff --git a/backend/app/DomainObjects/Generated/InvoiceDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/InvoiceDomainObjectAbstract.php new file mode 100644 index 0000000000..b67e8fde09 --- /dev/null +++ b/backend/app/DomainObjects/Generated/InvoiceDomainObjectAbstract.php @@ -0,0 +1,216 @@ + $this->id ?? null, + 'order_id' => $this->order_id ?? null, + 'account_id' => $this->account_id ?? null, + 'invoice_number' => $this->invoice_number ?? null, + 'issue_date' => $this->issue_date ?? null, + 'due_date' => $this->due_date ?? null, + 'total_amount' => $this->total_amount ?? null, + 'status' => $this->status ?? null, + 'items' => $this->items ?? null, + 'taxes_and_fees' => $this->taxes_and_fees ?? null, + 'uuid' => $this->uuid ?? 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 setOrderId(int $order_id): self + { + $this->order_id = $order_id; + return $this; + } + + public function getOrderId(): int + { + return $this->order_id; + } + + public function setAccountId(int $account_id): self + { + $this->account_id = $account_id; + return $this; + } + + public function getAccountId(): int + { + return $this->account_id; + } + + public function setInvoiceNumber(string $invoice_number): self + { + $this->invoice_number = $invoice_number; + return $this; + } + + public function getInvoiceNumber(): string + { + return $this->invoice_number; + } + + public function setIssueDate(string $issue_date): self + { + $this->issue_date = $issue_date; + return $this; + } + + public function getIssueDate(): string + { + return $this->issue_date; + } + + public function setDueDate(?string $due_date): self + { + $this->due_date = $due_date; + return $this; + } + + public function getDueDate(): ?string + { + return $this->due_date; + } + + public function setTotalAmount(float $total_amount): self + { + $this->total_amount = $total_amount; + return $this; + } + + public function getTotalAmount(): float + { + return $this->total_amount; + } + + public function setStatus(string $status): self + { + $this->status = $status; + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setItems(array|string $items): self + { + $this->items = $items; + return $this; + } + + public function getItems(): array|string + { + return $this->items; + } + + public function setTaxesAndFees(array|string|null $taxes_and_fees): self + { + $this->taxes_and_fees = $taxes_and_fees; + return $this; + } + + public function getTaxesAndFees(): array|string|null + { + return $this->taxes_and_fees; + } + + public function setUuid(string $uuid): self + { + $this->uuid = $uuid; + return $this; + } + + public function getUuid(): string + { + return $this->uuid; + } + + 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 c496d4ac0a..acb847efb0 100644 --- a/backend/app/DomainObjects/Generated/MessageDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/MessageDomainObjectAbstract.php @@ -19,7 +19,7 @@ abstract class MessageDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr final public const RECIPIENT_IDS = 'recipient_ids'; final public const SENT_AT = 'sent_at'; final public const ATTENDEE_IDS = 'attendee_ids'; - final public const TICKET_IDS = 'ticket_ids'; + final public const PRODUCT_IDS = 'product_ids'; final public const ORDER_ID = 'order_id'; final public const STATUS = 'status'; final public const SEND_DATA = 'send_data'; @@ -36,7 +36,7 @@ abstract class MessageDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr protected array|string|null $recipient_ids = null; protected ?string $sent_at = null; protected array|string|null $attendee_ids = null; - protected array|string|null $ticket_ids = null; + protected array|string|null $product_ids = null; protected ?int $order_id = null; protected string $status; protected array|string|null $send_data = null; @@ -56,7 +56,7 @@ public function toArray(): array 'recipient_ids' => $this->recipient_ids ?? null, 'sent_at' => $this->sent_at ?? null, 'attendee_ids' => $this->attendee_ids ?? null, - 'ticket_ids' => $this->ticket_ids ?? null, + 'product_ids' => $this->product_ids ?? null, 'order_id' => $this->order_id ?? null, 'status' => $this->status ?? null, 'send_data' => $this->send_data ?? null, @@ -165,15 +165,15 @@ public function getAttendeeIds(): array|string|null return $this->attendee_ids; } - public function setTicketIds(array|string|null $ticket_ids): self + public function setProductIds(array|string|null $product_ids): self { - $this->ticket_ids = $ticket_ids; + $this->product_ids = $product_ids; return $this; } - public function getTicketIds(): array|string|null + public function getProductIds(): array|string|null { - return $this->ticket_ids; + return $this->product_ids; } public function setOrderId(?int $order_id): self diff --git a/backend/app/DomainObjects/Generated/OrderApplicationFeeDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrderApplicationFeeDomainObjectAbstract.php new file mode 100644 index 0000000000..dbdeb02181 --- /dev/null +++ b/backend/app/DomainObjects/Generated/OrderApplicationFeeDomainObjectAbstract.php @@ -0,0 +1,174 @@ + $this->id ?? null, + 'order_id' => $this->order_id ?? null, + 'amount' => $this->amount ?? null, + 'currency' => $this->currency ?? null, + 'status' => $this->status ?? null, + 'payment_method' => $this->payment_method ?? null, + 'metadata' => $this->metadata ?? null, + 'paid_at' => $this->paid_at ?? 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 setOrderId(int $order_id): self + { + $this->order_id = $order_id; + return $this; + } + + public function getOrderId(): int + { + return $this->order_id; + } + + public function setAmount(float $amount): self + { + $this->amount = $amount; + return $this; + } + + public function getAmount(): float + { + return $this->amount; + } + + public function setCurrency(string $currency): self + { + $this->currency = $currency; + return $this; + } + + public function getCurrency(): string + { + return $this->currency; + } + + public function setStatus(string $status): self + { + $this->status = $status; + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setPaymentMethod(string $payment_method): self + { + $this->payment_method = $payment_method; + return $this; + } + + public function getPaymentMethod(): string + { + return $this->payment_method; + } + + public function setMetadata(array|string|null $metadata): self + { + $this->metadata = $metadata; + return $this; + } + + public function getMetadata(): array|string|null + { + return $this->metadata; + } + + public function setPaidAt(?string $paid_at): self + { + $this->paid_at = $paid_at; + return $this; + } + + public function getPaidAt(): ?string + { + return $this->paid_at; + } + + 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/OrderDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrderDomainObjectAbstract.php index 939ca2cd94..0d1e1ea2c4 100644 --- a/backend/app/DomainObjects/Generated/OrderDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/OrderDomainObjectAbstract.php @@ -39,6 +39,8 @@ abstract class OrderDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac final public const TOTAL_TAX = 'total_tax'; final public const TOTAL_FEE = 'total_fee'; final public const LOCALE = 'locale'; + final public const PAYMENT_PROVIDER = 'payment_provider'; + final public const NOTES = 'notes'; protected int $id; protected int $event_id; @@ -69,6 +71,8 @@ abstract class OrderDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac protected float $total_tax = 0.0; protected float $total_fee = 0.0; protected string $locale = 'en'; + protected ?string $payment_provider = null; + protected ?string $notes = null; public function toArray(): array { @@ -102,6 +106,8 @@ public function toArray(): array 'total_tax' => $this->total_tax ?? null, 'total_fee' => $this->total_fee ?? null, 'locale' => $this->locale ?? null, + 'payment_provider' => $this->payment_provider ?? null, + 'notes' => $this->notes ?? null, ]; } @@ -423,4 +429,26 @@ public function getLocale(): string { return $this->locale; } + + public function setPaymentProvider(?string $payment_provider): self + { + $this->payment_provider = $payment_provider; + return $this; + } + + public function getPaymentProvider(): ?string + { + return $this->payment_provider; + } + + public function setNotes(?string $notes): self + { + $this->notes = $notes; + return $this; + } + + public function getNotes(): ?string + { + return $this->notes; + } } diff --git a/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php index 71936c1922..076da8954c 100644 --- a/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php @@ -12,8 +12,8 @@ abstract class OrderItemDomainObjectAbstract extends \HiEvents\DomainObjects\Abs final public const PLURAL_NAME = 'order_items'; final public const ID = 'id'; final public const ORDER_ID = 'order_id'; - final public const TICKET_ID = 'ticket_id'; - final public const TICKET_PRICE_ID = 'ticket_price_id'; + final public const PRODUCT_ID = 'product_id'; + final public const PRODUCT_PRICE_ID = 'product_price_id'; final public const TOTAL_BEFORE_ADDITIONS = 'total_before_additions'; final public const QUANTITY = 'quantity'; final public const ITEM_NAME = 'item_name'; @@ -24,11 +24,12 @@ abstract class OrderItemDomainObjectAbstract extends \HiEvents\DomainObjects\Abs final public const TOTAL_GROSS = 'total_gross'; final public const TOTAL_SERVICE_FEE = 'total_service_fee'; final public const TAXES_AND_FEES_ROLLUP = 'taxes_and_fees_rollup'; + final public const PRODUCT_TYPE = 'product_type'; protected int $id; protected int $order_id; - protected int $ticket_id; - protected int $ticket_price_id; + protected int $product_id; + protected int $product_price_id; protected float $total_before_additions; protected int $quantity; protected ?string $item_name = null; @@ -39,14 +40,15 @@ abstract class OrderItemDomainObjectAbstract extends \HiEvents\DomainObjects\Abs protected ?float $total_gross = null; protected ?float $total_service_fee = 0.0; protected array|string|null $taxes_and_fees_rollup = null; + protected string $product_type = 'TICKET'; public function toArray(): array { return [ 'id' => $this->id ?? null, 'order_id' => $this->order_id ?? null, - 'ticket_id' => $this->ticket_id ?? null, - 'ticket_price_id' => $this->ticket_price_id ?? null, + 'product_id' => $this->product_id ?? null, + 'product_price_id' => $this->product_price_id ?? null, 'total_before_additions' => $this->total_before_additions ?? null, 'quantity' => $this->quantity ?? null, 'item_name' => $this->item_name ?? null, @@ -57,6 +59,7 @@ public function toArray(): array 'total_gross' => $this->total_gross ?? null, 'total_service_fee' => $this->total_service_fee ?? null, 'taxes_and_fees_rollup' => $this->taxes_and_fees_rollup ?? null, + 'product_type' => $this->product_type ?? null, ]; } @@ -82,26 +85,26 @@ public function getOrderId(): int return $this->order_id; } - public function setTicketId(int $ticket_id): self + public function setProductId(int $product_id): self { - $this->ticket_id = $ticket_id; + $this->product_id = $product_id; return $this; } - public function getTicketId(): int + public function getProductId(): int { - return $this->ticket_id; + return $this->product_id; } - public function setTicketPriceId(int $ticket_price_id): self + public function setProductPriceId(int $product_price_id): self { - $this->ticket_price_id = $ticket_price_id; + $this->product_price_id = $product_price_id; return $this; } - public function getTicketPriceId(): int + public function getProductPriceId(): int { - return $this->ticket_price_id; + return $this->product_price_id; } public function setTotalBeforeAdditions(float $total_before_additions): self @@ -213,4 +216,15 @@ public function getTaxesAndFeesRollup(): array|string|null { return $this->taxes_and_fees_rollup; } + + public function setProductType(string $product_type): self + { + $this->product_type = $product_type; + return $this; + } + + public function getProductType(): string + { + return $this->product_type; + } } diff --git a/backend/app/DomainObjects/Generated/OrderRefundDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrderRefundDomainObjectAbstract.php new file mode 100644 index 0000000000..6db78fe757 --- /dev/null +++ b/backend/app/DomainObjects/Generated/OrderRefundDomainObjectAbstract.php @@ -0,0 +1,188 @@ + $this->id ?? null, + 'order_id' => $this->order_id ?? null, + 'payment_provider' => $this->payment_provider ?? null, + 'refund_id' => $this->refund_id ?? null, + 'amount' => $this->amount ?? null, + 'currency' => $this->currency ?? null, + 'status' => $this->status ?? null, + 'reason' => $this->reason ?? null, + 'metadata' => $this->metadata ?? 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 setOrderId(int $order_id): self + { + $this->order_id = $order_id; + return $this; + } + + public function getOrderId(): int + { + return $this->order_id; + } + + public function setPaymentProvider(string $payment_provider): self + { + $this->payment_provider = $payment_provider; + return $this; + } + + public function getPaymentProvider(): string + { + return $this->payment_provider; + } + + public function setRefundId(string $refund_id): self + { + $this->refund_id = $refund_id; + return $this; + } + + public function getRefundId(): string + { + return $this->refund_id; + } + + public function setAmount(float $amount): self + { + $this->amount = $amount; + return $this; + } + + public function getAmount(): float + { + return $this->amount; + } + + public function setCurrency(string $currency): self + { + $this->currency = $currency; + return $this; + } + + public function getCurrency(): string + { + return $this->currency; + } + + public function setStatus(?string $status): self + { + $this->status = $status; + return $this; + } + + public function getStatus(): ?string + { + return $this->status; + } + + public function setReason(?string $reason): self + { + $this->reason = $reason; + return $this; + } + + public function getReason(): ?string + { + return $this->reason; + } + + public function setMetadata(array|string|null $metadata): self + { + $this->metadata = $metadata; + return $this; + } + + public function getMetadata(): array|string|null + { + return $this->metadata; + } + + 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/PersonalAccessTokenDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/PersonalAccessTokenDomainObjectAbstract.php index 4a58970723..25b819189c 100644 --- a/backend/app/DomainObjects/Generated/PersonalAccessTokenDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/PersonalAccessTokenDomainObjectAbstract.php @@ -11,6 +11,7 @@ abstract class PersonalAccessTokenDomainObjectAbstract extends \HiEvents\DomainO final public const SINGULAR_NAME = 'personal_access_token'; final public const PLURAL_NAME = 'personal_access_tokens'; final public const ID = 'id'; + final public const ACCOUNT_ID = 'account_id'; final public const TOKENABLE_TYPE = 'tokenable_type'; final public const TOKENABLE_ID = 'tokenable_id'; final public const NAME = 'name'; @@ -22,6 +23,7 @@ abstract class PersonalAccessTokenDomainObjectAbstract extends \HiEvents\DomainO final public const UPDATED_AT = 'updated_at'; protected int $id; + protected int $account_id; protected string $tokenable_type; protected int $tokenable_id; protected string $name; @@ -36,6 +38,7 @@ public function toArray(): array { return [ 'id' => $this->id ?? null, + 'account_id' => $this->account_id ?? null, 'tokenable_type' => $this->tokenable_type ?? null, 'tokenable_id' => $this->tokenable_id ?? null, 'name' => $this->name ?? null, @@ -59,6 +62,17 @@ public function getId(): int return $this->id; } + public function setAccountId(int $account_id): self + { + $this->account_id = $account_id; + return $this; + } + + public function getAccountId(): int + { + return $this->account_id; + } + public function setTokenableType(string $tokenable_type): self { $this->tokenable_type = $tokenable_type; diff --git a/backend/app/DomainObjects/Generated/TicketCapacityAssignmentDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/ProductCapacityAssignmentDomainObjectAbstract.php similarity index 79% rename from backend/app/DomainObjects/Generated/TicketCapacityAssignmentDomainObjectAbstract.php rename to backend/app/DomainObjects/Generated/ProductCapacityAssignmentDomainObjectAbstract.php index 40ed859361..048264f454 100644 --- a/backend/app/DomainObjects/Generated/TicketCapacityAssignmentDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/ProductCapacityAssignmentDomainObjectAbstract.php @@ -6,19 +6,19 @@ * THIS FILE IS AUTOGENERATED - DO NOT EDIT IT DIRECTLY. * @package HiEvents\DomainObjects\Generated */ -abstract class TicketCapacityAssignmentDomainObjectAbstract extends \HiEvents\DomainObjects\AbstractDomainObject +abstract class ProductCapacityAssignmentDomainObjectAbstract extends \HiEvents\DomainObjects\AbstractDomainObject { - final public const SINGULAR_NAME = 'ticket_capacity_assignment'; - final public const PLURAL_NAME = 'ticket_capacity_assignments'; + final public const SINGULAR_NAME = 'product_capacity_assignment'; + final public const PLURAL_NAME = 'product_capacity_assignments'; final public const ID = 'id'; - final public const TICKET_ID = 'ticket_id'; + final public const PRODUCT_ID = 'product_id'; final public const CAPACITY_ASSIGNMENT_ID = 'capacity_assignment_id'; final public const CREATED_AT = 'created_at'; final public const UPDATED_AT = 'updated_at'; final public const DELETED_AT = 'deleted_at'; protected int $id; - protected int $ticket_id; + protected int $product_id; protected int $capacity_assignment_id; protected ?string $created_at = null; protected ?string $updated_at = null; @@ -28,7 +28,7 @@ public function toArray(): array { return [ 'id' => $this->id ?? null, - 'ticket_id' => $this->ticket_id ?? null, + 'product_id' => $this->product_id ?? null, 'capacity_assignment_id' => $this->capacity_assignment_id ?? null, 'created_at' => $this->created_at ?? null, 'updated_at' => $this->updated_at ?? null, @@ -47,15 +47,15 @@ public function getId(): int return $this->id; } - public function setTicketId(int $ticket_id): self + public function setProductId(int $product_id): self { - $this->ticket_id = $ticket_id; + $this->product_id = $product_id; return $this; } - public function getTicketId(): int + public function getProductId(): int { - return $this->ticket_id; + return $this->product_id; } public function setCapacityAssignmentId(int $capacity_assignment_id): self diff --git a/backend/app/DomainObjects/Generated/ProductCategoryDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/ProductCategoryDomainObjectAbstract.php new file mode 100644 index 0000000000..19bc308de4 --- /dev/null +++ b/backend/app/DomainObjects/Generated/ProductCategoryDomainObjectAbstract.php @@ -0,0 +1,160 @@ + $this->id ?? null, + 'event_id' => $this->event_id ?? null, + 'name' => $this->name ?? null, + 'no_products_message' => $this->no_products_message ?? null, + 'description' => $this->description ?? null, + 'is_hidden' => $this->is_hidden ?? null, + 'order' => $this->order ?? 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 setName(string $name): self + { + $this->name = $name; + return $this; + } + + public function getName(): string + { + return $this->name; + } + + public function setNoProductsMessage(?string $no_products_message): self + { + $this->no_products_message = $no_products_message; + return $this; + } + + public function getNoProductsMessage(): ?string + { + return $this->no_products_message; + } + + public function setDescription(?string $description): self + { + $this->description = $description; + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setIsHidden(bool $is_hidden): self + { + $this->is_hidden = $is_hidden; + return $this; + } + + public function getIsHidden(): bool + { + return $this->is_hidden; + } + + public function setOrder(int $order): self + { + $this->order = $order; + return $this; + } + + public function getOrder(): int + { + return $this->order; + } + + 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/TicketsCheckInListDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/ProductCheckInListDomainObjectAbstract.php similarity index 73% rename from backend/app/DomainObjects/Generated/TicketsCheckInListDomainObjectAbstract.php rename to backend/app/DomainObjects/Generated/ProductCheckInListDomainObjectAbstract.php index da5209eae2..427251d755 100644 --- a/backend/app/DomainObjects/Generated/TicketsCheckInListDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/ProductCheckInListDomainObjectAbstract.php @@ -6,17 +6,17 @@ * THIS FILE IS AUTOGENERATED - DO NOT EDIT IT DIRECTLY. * @package HiEvents\DomainObjects\Generated */ -abstract class TicketsCheckInListDomainObjectAbstract extends \HiEvents\DomainObjects\AbstractDomainObject +abstract class ProductCheckInListDomainObjectAbstract extends \HiEvents\DomainObjects\AbstractDomainObject { - final public const SINGULAR_NAME = 'tickets_check_in_list'; - final public const PLURAL_NAME = 'tickets_check_in_lists'; + final public const SINGULAR_NAME = 'product_check_in_list'; + final public const PLURAL_NAME = 'product_check_in_lists'; final public const ID = 'id'; - final public const TICKET_ID = 'ticket_id'; + final public const PRODUCT_ID = 'product_id'; final public const CHECK_IN_LIST_ID = 'check_in_list_id'; final public const DELETED_AT = 'deleted_at'; protected int $id; - protected int $ticket_id; + protected int $product_id; protected int $check_in_list_id; protected ?string $deleted_at = null; @@ -24,7 +24,7 @@ public function toArray(): array { return [ 'id' => $this->id ?? null, - 'ticket_id' => $this->ticket_id ?? null, + 'product_id' => $this->product_id ?? null, 'check_in_list_id' => $this->check_in_list_id ?? null, 'deleted_at' => $this->deleted_at ?? null, ]; @@ -41,15 +41,15 @@ public function getId(): int return $this->id; } - public function setTicketId(int $ticket_id): self + public function setProductId(int $product_id): self { - $this->ticket_id = $ticket_id; + $this->product_id = $product_id; return $this; } - public function getTicketId(): int + public function getProductId(): int { - return $this->ticket_id; + return $this->product_id; } public function setCheckInListId(int $check_in_list_id): self diff --git a/backend/app/DomainObjects/Generated/TicketDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php similarity index 89% rename from backend/app/DomainObjects/Generated/TicketDomainObjectAbstract.php rename to backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php index 691ce41e52..41d8a4cadd 100644 --- a/backend/app/DomainObjects/Generated/TicketDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php @@ -6,12 +6,13 @@ * THIS FILE IS AUTOGENERATED - DO NOT EDIT IT DIRECTLY. * @package HiEvents\DomainObjects\Generated */ -abstract class TicketDomainObjectAbstract extends \HiEvents\DomainObjects\AbstractDomainObject +abstract class ProductDomainObjectAbstract extends \HiEvents\DomainObjects\AbstractDomainObject { - final public const SINGULAR_NAME = 'ticket'; - final public const PLURAL_NAME = 'tickets'; + final public const SINGULAR_NAME = 'product'; + final public const PLURAL_NAME = 'products'; final public const ID = 'id'; final public const EVENT_ID = 'event_id'; + final public const PRODUCT_CATEGORY_ID = 'product_category_id'; final public const TITLE = 'title'; final public const SALE_START_DATE = 'sale_start_date'; final public const SALE_END_DATE = 'sale_end_date'; @@ -31,10 +32,12 @@ abstract class TicketDomainObjectAbstract extends \HiEvents\DomainObjects\Abstra final public const DELETED_AT = 'deleted_at'; final public const TYPE = 'type'; final public const IS_HIDDEN = 'is_hidden'; + final public const PRODUCT_TYPE = 'product_type'; final public const START_COLLAPSED = 'start_collapsed'; protected int $id; protected int $event_id; + protected ?int $product_category_id = null; protected string $title; protected ?string $sale_start_date = null; protected ?string $sale_end_date = null; @@ -54,6 +57,7 @@ abstract class TicketDomainObjectAbstract extends \HiEvents\DomainObjects\Abstra protected ?string $deleted_at = null; protected string $type = 'PAID'; protected ?bool $is_hidden = false; + protected string $product_type = 'TICKET'; protected bool $start_collapsed = false; public function toArray(): array @@ -61,6 +65,7 @@ public function toArray(): array return [ 'id' => $this->id ?? null, 'event_id' => $this->event_id ?? null, + 'product_category_id' => $this->product_category_id ?? null, 'title' => $this->title ?? null, 'sale_start_date' => $this->sale_start_date ?? null, 'sale_end_date' => $this->sale_end_date ?? null, @@ -80,6 +85,7 @@ public function toArray(): array 'deleted_at' => $this->deleted_at ?? null, 'type' => $this->type ?? null, 'is_hidden' => $this->is_hidden ?? null, + 'product_type' => $this->product_type ?? null, 'start_collapsed' => $this->start_collapsed ?? null, ]; } @@ -106,6 +112,17 @@ public function getEventId(): int return $this->event_id; } + public function setProductCategoryId(?int $product_category_id): self + { + $this->product_category_id = $product_category_id; + return $this; + } + + public function getProductCategoryId(): ?int + { + return $this->product_category_id; + } + public function setTitle(string $title): self { $this->title = $title; @@ -315,6 +332,17 @@ public function getIsHidden(): ?bool return $this->is_hidden; } + public function setProductType(string $product_type): self + { + $this->product_type = $product_type; + return $this; + } + + public function getProductType(): string + { + return $this->product_type; + } + public function setStartCollapsed(bool $start_collapsed): self { $this->start_collapsed = $start_collapsed; diff --git a/backend/app/DomainObjects/Generated/TicketPriceDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/ProductPriceDomainObjectAbstract.php similarity index 90% rename from backend/app/DomainObjects/Generated/TicketPriceDomainObjectAbstract.php rename to backend/app/DomainObjects/Generated/ProductPriceDomainObjectAbstract.php index c6e47272c4..ee1f14577a 100644 --- a/backend/app/DomainObjects/Generated/TicketPriceDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/ProductPriceDomainObjectAbstract.php @@ -6,12 +6,12 @@ * THIS FILE IS AUTOGENERATED - DO NOT EDIT IT DIRECTLY. * @package HiEvents\DomainObjects\Generated */ -abstract class TicketPriceDomainObjectAbstract extends \HiEvents\DomainObjects\AbstractDomainObject +abstract class ProductPriceDomainObjectAbstract extends \HiEvents\DomainObjects\AbstractDomainObject { - final public const SINGULAR_NAME = 'ticket_price'; - final public const PLURAL_NAME = 'ticket_prices'; + final public const SINGULAR_NAME = 'product_price'; + final public const PLURAL_NAME = 'product_prices'; final public const ID = 'id'; - final public const TICKET_ID = 'ticket_id'; + final public const PRODUCT_ID = 'product_id'; final public const PRICE = 'price'; final public const LABEL = 'label'; final public const SALE_START_DATE = 'sale_start_date'; @@ -26,7 +26,7 @@ abstract class TicketPriceDomainObjectAbstract extends \HiEvents\DomainObjects\A final public const QUANTITY_AVAILABLE = 'quantity_available'; protected int $id; - protected int $ticket_id; + protected int $product_id; protected float $price; protected ?string $label = null; protected ?string $sale_start_date = null; @@ -44,7 +44,7 @@ public function toArray(): array { return [ 'id' => $this->id ?? null, - 'ticket_id' => $this->ticket_id ?? null, + 'product_id' => $this->product_id ?? null, 'price' => $this->price ?? null, 'label' => $this->label ?? null, 'sale_start_date' => $this->sale_start_date ?? null, @@ -71,15 +71,15 @@ public function getId(): int return $this->id; } - public function setTicketId(int $ticket_id): self + public function setProductId(int $product_id): self { - $this->ticket_id = $ticket_id; + $this->product_id = $product_id; return $this; } - public function getTicketId(): int + public function getProductId(): int { - return $this->ticket_id; + return $this->product_id; } public function setPrice(float $price): self diff --git a/backend/app/DomainObjects/Generated/TicketQuestionDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/ProductQuestionDomainObjectAbstract.php similarity index 71% rename from backend/app/DomainObjects/Generated/TicketQuestionDomainObjectAbstract.php rename to backend/app/DomainObjects/Generated/ProductQuestionDomainObjectAbstract.php index 7aaba00d1c..7b3cbebfb4 100644 --- a/backend/app/DomainObjects/Generated/TicketQuestionDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/ProductQuestionDomainObjectAbstract.php @@ -6,17 +6,17 @@ * THIS FILE IS AUTOGENERATED - DO NOT EDIT IT DIRECTLY. * @package HiEvents\DomainObjects\Generated */ -abstract class TicketQuestionDomainObjectAbstract extends \HiEvents\DomainObjects\AbstractDomainObject +abstract class ProductQuestionDomainObjectAbstract extends \HiEvents\DomainObjects\AbstractDomainObject { - final public const SINGULAR_NAME = 'ticket_question'; - final public const PLURAL_NAME = 'ticket_questions'; + final public const SINGULAR_NAME = 'product_question'; + final public const PLURAL_NAME = 'product_questions'; final public const ID = 'id'; - final public const TICKET_ID = 'ticket_id'; + final public const PRODUCT_ID = 'product_id'; final public const QUESTION_ID = 'question_id'; final public const DELETED_AT = 'deleted_at'; protected int $id; - protected int $ticket_id; + protected int $product_id; protected int $question_id; protected ?string $deleted_at = null; @@ -24,7 +24,7 @@ public function toArray(): array { return [ 'id' => $this->id ?? null, - 'ticket_id' => $this->ticket_id ?? null, + 'product_id' => $this->product_id ?? null, 'question_id' => $this->question_id ?? null, 'deleted_at' => $this->deleted_at ?? null, ]; @@ -41,15 +41,15 @@ public function getId(): int return $this->id; } - public function setTicketId(int $ticket_id): self + public function setProductId(int $product_id): self { - $this->ticket_id = $ticket_id; + $this->product_id = $product_id; return $this; } - public function getTicketId(): int + public function getProductId(): int { - return $this->ticket_id; + return $this->product_id; } public function setQuestionId(int $question_id): self diff --git a/backend/app/DomainObjects/Generated/TicketTaxAndFeesDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/ProductTaxAndFeesDomainObjectAbstract.php similarity index 64% rename from backend/app/DomainObjects/Generated/TicketTaxAndFeesDomainObjectAbstract.php rename to backend/app/DomainObjects/Generated/ProductTaxAndFeesDomainObjectAbstract.php index f4f59d977d..6c1b7840a8 100644 --- a/backend/app/DomainObjects/Generated/TicketTaxAndFeesDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/ProductTaxAndFeesDomainObjectAbstract.php @@ -6,23 +6,23 @@ * THIS FILE IS AUTOGENERATED - DO NOT EDIT IT DIRECTLY. * @package HiEvents\DomainObjects\Generated */ -abstract class TicketTaxAndFeesDomainObjectAbstract extends \HiEvents\DomainObjects\AbstractDomainObject +abstract class ProductTaxAndFeesDomainObjectAbstract extends \HiEvents\DomainObjects\AbstractDomainObject { - final public const SINGULAR_NAME = 'ticket_tax_and_fees'; - final public const PLURAL_NAME = 'ticket_tax_and_fees'; + final public const SINGULAR_NAME = 'product_tax_and_fees'; + final public const PLURAL_NAME = 'product_tax_and_fees'; final public const ID = 'id'; - final public const TICKET_ID = 'ticket_id'; + final public const PRODUCT_ID = 'product_id'; final public const TAX_AND_FEE_ID = 'tax_and_fee_id'; protected int $id; - protected int $ticket_id; + protected int $product_id; protected int $tax_and_fee_id; public function toArray(): array { return [ 'id' => $this->id ?? null, - 'ticket_id' => $this->ticket_id ?? null, + 'product_id' => $this->product_id ?? null, 'tax_and_fee_id' => $this->tax_and_fee_id ?? null, ]; } @@ -38,15 +38,15 @@ public function getId(): int return $this->id; } - public function setTicketId(int $ticket_id): self + public function setProductId(int $product_id): self { - $this->ticket_id = $ticket_id; + $this->product_id = $product_id; return $this; } - public function getTicketId(): int + public function getProductId(): int { - return $this->ticket_id; + return $this->product_id; } public function setTaxAndFeeId(int $tax_and_fee_id): self diff --git a/backend/app/DomainObjects/Generated/PromoCodeDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/PromoCodeDomainObjectAbstract.php index f4bda2b518..c1e7174e18 100644 --- a/backend/app/DomainObjects/Generated/PromoCodeDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/PromoCodeDomainObjectAbstract.php @@ -14,7 +14,7 @@ abstract class PromoCodeDomainObjectAbstract extends \HiEvents\DomainObjects\Abs final public const EVENT_ID = 'event_id'; final public const CODE = 'code'; final public const DISCOUNT = 'discount'; - final public const APPLICABLE_TICKET_IDS = 'applicable_ticket_ids'; + final public const APPLICABLE_PRODUCT_IDS = 'applicable_product_ids'; final public const EXPIRY_DATE = 'expiry_date'; final public const DISCOUNT_TYPE = 'discount_type'; final public const ATTENDEE_USAGE_COUNT = 'attendee_usage_count'; @@ -28,7 +28,7 @@ abstract class PromoCodeDomainObjectAbstract extends \HiEvents\DomainObjects\Abs protected int $event_id; protected string $code; protected float $discount = 0.0; - protected array|string|null $applicable_ticket_ids = null; + protected array|string|null $applicable_product_ids = null; protected ?string $expiry_date = null; protected ?string $discount_type = null; protected int $attendee_usage_count = 0; @@ -45,7 +45,7 @@ public function toArray(): array 'event_id' => $this->event_id ?? null, 'code' => $this->code ?? null, 'discount' => $this->discount ?? null, - 'applicable_ticket_ids' => $this->applicable_ticket_ids ?? null, + 'applicable_product_ids' => $this->applicable_product_ids ?? null, 'expiry_date' => $this->expiry_date ?? null, 'discount_type' => $this->discount_type ?? null, 'attendee_usage_count' => $this->attendee_usage_count ?? null, @@ -101,15 +101,15 @@ public function getDiscount(): float return $this->discount; } - public function setApplicableTicketIds(array|string|null $applicable_ticket_ids): self + public function setApplicableProductIds(array|string|null $applicable_product_ids): self { - $this->applicable_ticket_ids = $applicable_ticket_ids; + $this->applicable_product_ids = $applicable_product_ids; return $this; } - public function getApplicableTicketIds(): array|string|null + public function getApplicableProductIds(): array|string|null { - return $this->applicable_ticket_ids; + return $this->applicable_product_ids; } public function setExpiryDate(?string $expiry_date): self diff --git a/backend/app/DomainObjects/Generated/QuestionAnswerDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/QuestionAnswerDomainObjectAbstract.php index f71fc5ae13..0505f94c3e 100644 --- a/backend/app/DomainObjects/Generated/QuestionAnswerDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/QuestionAnswerDomainObjectAbstract.php @@ -14,7 +14,7 @@ abstract class QuestionAnswerDomainObjectAbstract extends \HiEvents\DomainObject final public const QUESTION_ID = 'question_id'; final public const ORDER_ID = 'order_id'; final public const ATTENDEE_ID = 'attendee_id'; - final public const TICKET_ID = 'ticket_id'; + final public const PRODUCT_ID = 'product_id'; final public const CREATED_AT = 'created_at'; final public const UPDATED_AT = 'updated_at'; final public const DELETED_AT = 'deleted_at'; @@ -24,7 +24,7 @@ abstract class QuestionAnswerDomainObjectAbstract extends \HiEvents\DomainObject protected int $question_id; protected int $order_id; protected ?int $attendee_id = null; - protected ?int $ticket_id = null; + protected ?int $product_id = null; protected string $created_at; protected string $updated_at; protected ?string $deleted_at = null; @@ -37,7 +37,7 @@ public function toArray(): array 'question_id' => $this->question_id ?? null, 'order_id' => $this->order_id ?? null, 'attendee_id' => $this->attendee_id ?? null, - 'ticket_id' => $this->ticket_id ?? null, + 'product_id' => $this->product_id ?? null, 'created_at' => $this->created_at ?? null, 'updated_at' => $this->updated_at ?? null, 'deleted_at' => $this->deleted_at ?? null, @@ -89,15 +89,15 @@ public function getAttendeeId(): ?int return $this->attendee_id; } - public function setTicketId(?int $ticket_id): self + public function setProductId(?int $product_id): self { - $this->ticket_id = $ticket_id; + $this->product_id = $product_id; return $this; } - public function getTicketId(): ?int + public function getProductId(): ?int { - return $this->ticket_id; + return $this->product_id; } public function setCreatedAt(string $created_at): self diff --git a/backend/app/DomainObjects/Generated/StripeCustomerDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/StripeCustomerDomainObjectAbstract.php index 66b6f685a7..a0ebe1e25d 100644 --- a/backend/app/DomainObjects/Generated/StripeCustomerDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/StripeCustomerDomainObjectAbstract.php @@ -17,6 +17,7 @@ abstract class StripeCustomerDomainObjectAbstract extends \HiEvents\DomainObject final public const CREATED_AT = 'created_at'; final public const UPDATED_AT = 'updated_at'; final public const DELETED_AT = 'deleted_at'; + final public const STRIPE_ACCOUNT_ID = 'stripe_account_id'; protected int $id; protected string $name; @@ -25,6 +26,7 @@ abstract class StripeCustomerDomainObjectAbstract extends \HiEvents\DomainObject protected ?string $created_at = null; protected ?string $updated_at = null; protected ?string $deleted_at = null; + protected ?string $stripe_account_id = null; public function toArray(): array { @@ -36,6 +38,7 @@ public function toArray(): array 'created_at' => $this->created_at ?? null, 'updated_at' => $this->updated_at ?? null, 'deleted_at' => $this->deleted_at ?? null, + 'stripe_account_id' => $this->stripe_account_id ?? null, ]; } @@ -115,4 +118,15 @@ public function getDeletedAt(): ?string { return $this->deleted_at; } + + 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; + } } diff --git a/backend/app/DomainObjects/Generated/StripePaymentDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/StripePaymentDomainObjectAbstract.php index 9b947403d7..a09510601b 100644 --- a/backend/app/DomainObjects/Generated/StripePaymentDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/StripePaymentDomainObjectAbstract.php @@ -21,6 +21,7 @@ abstract class StripePaymentDomainObjectAbstract extends \HiEvents\DomainObjects final public const DELETED_AT = 'deleted_at'; final public const LAST_ERROR = 'last_error'; final public const CONNECTED_ACCOUNT_ID = 'connected_account_id'; + final public const APPLICATION_FEE = 'application_fee'; protected int $id; protected int $order_id; @@ -33,6 +34,7 @@ abstract class StripePaymentDomainObjectAbstract extends \HiEvents\DomainObjects protected ?string $deleted_at = null; protected array|string|null $last_error = null; protected ?string $connected_account_id = null; + protected int $application_fee = 0; public function toArray(): array { @@ -48,6 +50,7 @@ public function toArray(): array 'deleted_at' => $this->deleted_at ?? null, 'last_error' => $this->last_error ?? null, 'connected_account_id' => $this->connected_account_id ?? null, + 'application_fee' => $this->application_fee ?? null, ]; } @@ -171,4 +174,15 @@ public function getConnectedAccountId(): ?string { return $this->connected_account_id; } + + public function setApplicationFee(int $application_fee): self + { + $this->application_fee = $application_fee; + return $this; + } + + public function getApplicationFee(): int + { + return $this->application_fee; + } } diff --git a/backend/app/DomainObjects/Generated/TicketCheckInListDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/TicketCheckInListDomainObjectAbstract.php deleted file mode 100644 index 6b4722ada8..0000000000 --- a/backend/app/DomainObjects/Generated/TicketCheckInListDomainObjectAbstract.php +++ /dev/null @@ -1,76 +0,0 @@ - $this->id ?? null, - 'ticket_id' => $this->ticket_id ?? null, - 'check_in_list_id' => $this->check_in_list_id ?? 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 setTicketId(int $ticket_id): self - { - $this->ticket_id = $ticket_id; - return $this; - } - - public function getTicketId(): int - { - return $this->ticket_id; - } - - public function setCheckInListId(int $check_in_list_id): self - { - $this->check_in_list_id = $check_in_list_id; - return $this; - } - - public function getCheckInListId(): int - { - return $this->check_in_list_id; - } - - 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/TicketTaxDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/TicketTaxDomainObjectAbstract.php deleted file mode 100644 index 66295f559e..0000000000 --- a/backend/app/DomainObjects/Generated/TicketTaxDomainObjectAbstract.php +++ /dev/null @@ -1,62 +0,0 @@ - $this->id ?? null, - 'ticket_id' => $this->ticket_id ?? null, - 'tax_id' => $this->tax_id ?? null, - ]; - } - - public function setId(int $id): self - { - $this->id = $id; - return $this; - } - - public function getId(): int - { - return $this->id; - } - - public function setTicketId(int $ticket_id): self - { - $this->ticket_id = $ticket_id; - return $this; - } - - public function getTicketId(): int - { - return $this->ticket_id; - } - - public function setTaxId(int $tax_id): self - { - $this->tax_id = $tax_id; - return $this; - } - - public function getTaxId(): int - { - return $this->tax_id; - } -} diff --git a/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php new file mode 100644 index 0000000000..5527c28818 --- /dev/null +++ b/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php @@ -0,0 +1,216 @@ + $this->id ?? null, + 'user_id' => $this->user_id ?? null, + 'event_id' => $this->event_id ?? null, + 'account_id' => $this->account_id ?? null, + 'url' => $this->url ?? null, + 'event_types' => $this->event_types ?? null, + 'last_response_code' => $this->last_response_code ?? null, + 'last_response_body' => $this->last_response_body ?? null, + 'last_triggered_at' => $this->last_triggered_at ?? null, + 'status' => $this->status ?? null, + 'secret' => $this->secret ?? 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 setUserId(int $user_id): self + { + $this->user_id = $user_id; + return $this; + } + + public function getUserId(): int + { + return $this->user_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 setAccountId(int $account_id): self + { + $this->account_id = $account_id; + return $this; + } + + public function getAccountId(): int + { + return $this->account_id; + } + + public function setUrl(string $url): self + { + $this->url = $url; + return $this; + } + + public function getUrl(): string + { + return $this->url; + } + + public function setEventTypes(array|string $event_types): self + { + $this->event_types = $event_types; + return $this; + } + + public function getEventTypes(): array|string + { + return $this->event_types; + } + + public function setLastResponseCode(?int $last_response_code): self + { + $this->last_response_code = $last_response_code; + return $this; + } + + public function getLastResponseCode(): ?int + { + return $this->last_response_code; + } + + public function setLastResponseBody(?string $last_response_body): self + { + $this->last_response_body = $last_response_body; + return $this; + } + + public function getLastResponseBody(): ?string + { + return $this->last_response_body; + } + + public function setLastTriggeredAt(?string $last_triggered_at): self + { + $this->last_triggered_at = $last_triggered_at; + return $this; + } + + public function getLastTriggeredAt(): ?string + { + return $this->last_triggered_at; + } + + public function setStatus(string $status): self + { + $this->status = $status; + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setSecret(string $secret): self + { + $this->secret = $secret; + return $this; + } + + public function getSecret(): string + { + return $this->secret; + } + + 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/WebhookLogDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/WebhookLogDomainObjectAbstract.php new file mode 100644 index 0000000000..bd70bb6687 --- /dev/null +++ b/backend/app/DomainObjects/Generated/WebhookLogDomainObjectAbstract.php @@ -0,0 +1,146 @@ + $this->id ?? null, + 'webhook_id' => $this->webhook_id ?? null, + 'payload' => $this->payload ?? null, + 'event_type' => $this->event_type ?? null, + 'response_code' => $this->response_code ?? null, + 'response_body' => $this->response_body ?? 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 setWebhookId(int $webhook_id): self + { + $this->webhook_id = $webhook_id; + return $this; + } + + public function getWebhookId(): int + { + return $this->webhook_id; + } + + public function setPayload(string $payload): self + { + $this->payload = $payload; + return $this; + } + + public function getPayload(): string + { + return $this->payload; + } + + public function setEventType(string $event_type): self + { + $this->event_type = $event_type; + return $this; + } + + public function getEventType(): string + { + return $this->event_type; + } + + public function setResponseCode(?int $response_code): self + { + $this->response_code = $response_code; + return $this; + } + + public function getResponseCode(): ?int + { + return $this->response_code; + } + + public function setResponseBody(?string $response_body): self + { + $this->response_body = $response_body; + return $this; + } + + public function getResponseBody(): ?string + { + return $this->response_body; + } + + 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/InvoiceDomainObject.php b/backend/app/DomainObjects/InvoiceDomainObject.php new file mode 100644 index 0000000000..65af110ff7 --- /dev/null +++ b/backend/app/DomainObjects/InvoiceDomainObject.php @@ -0,0 +1,34 @@ +order; + } + + public function setOrder(?OrderDomainObject $order): self + { + $this->order = $order; + + return $this; + } + + public function getEvent(): ?EventDomainObject + { + return $this->event; + } + + public function setEvent(?EventDomainObject $event): self + { + $this->event = $event; + + return $this; + } +} diff --git a/backend/app/DomainObjects/OrderApplicationFeeDomainObject.php b/backend/app/DomainObjects/OrderApplicationFeeDomainObject.php new file mode 100644 index 0000000000..1c3330d689 --- /dev/null +++ b/backend/app/DomainObjects/OrderApplicationFeeDomainObject.php @@ -0,0 +1,7 @@ +|null */ public ?Collection $orderItems = null; @@ -21,8 +25,26 @@ class OrderDomainObject extends Generated\OrderDomainObjectAbstract implements I /** @var Collection|null */ public ?Collection $questionAndAnswerViews = null; + public ?Collection $invoices = null; + public ?EventDomainObject $event = null; + public static function getAllowedFilterFields(): array + { + return [ + self::STATUS, + self::PAYMENT_STATUS, + self::REFUND_STATUS, + self::CREATED_AT, + self::FIRST_NAME, + self::LAST_NAME, + self::EMAIL, + self::PUBLIC_ID, + self::CURRENCY, + self::TOTAL_GROSS, + ]; + } + public static function getAllowedSorts(): AllowedSorts { return new AllowedSorts( @@ -66,6 +88,28 @@ public function getFullName(): string return $this->getFirstName() . ' ' . $this->getLastName(); } + public function getProductOrderItems(): Collection + { + if ($this->getOrderItems() === null) { + return new Collection(); + } + + return $this->getOrderItems()->filter(static function (OrderItemDomainObject $orderItem) { + return $orderItem->getProductType() === ProductType::GENERAL->name; + }); + } + + public function getTicketOrderItems(): Collection + { + if ($this->getOrderItems() === null) { + return new Collection(); + } + + return $this->getOrderItems()->filter(static function (OrderItemDomainObject $orderItem) { + return $orderItem->getProductType() === ProductType::TICKET->name; + }); + } + public function setOrderItems(?Collection $orderItems): OrderDomainObject { $this->orderItems = $orderItems; @@ -96,6 +140,11 @@ public function isPaymentRequired(): bool return (int)ceil($this->getTotalGross()) > 0; } + public function isOrderAwaitingOfflinePayment(): bool + { + return $this->getStatus() === OrderStatus::AWAITING_OFFLINE_PAYMENT->name; + } + public function isOrderCompleted(): bool { return $this->getStatus() === OrderStatus::COMPLETED->name; @@ -106,6 +155,16 @@ public function isOrderCancelled(): bool return $this->getStatus() === OrderStatus::CANCELLED->name; } + public function isOrderReserved(): bool + { + return $this->getStatus() === OrderStatus::RESERVED->name; + } + + public function isReservedOrderExpired(): bool + { + return (new Carbon($this->getReservedUntil()))->isPast(); + } + public function isOrderFailed(): bool { return $this->getPaymentStatus() === OrderPaymentStatus::PAYMENT_FAILED->name; @@ -124,7 +183,32 @@ public function isPartiallyRefunded(): bool public function isFullyRefunded(): bool { - return $this->getTotalRefunded() >= $this->getTotalGross(); + return !$this->isFreeOrder() && ($this->getTotalRefunded() >= $this->getTotalGross()); + } + + public function getHumanReadableStatus(): string + { + return OrderStatus::getHumanReadableStatus($this->getStatus()); + } + + public function getBillingAddressString(): string + { + return AddressHelper::formatAddress($this->getAddress()); + } + + public function getHasTaxes(): bool + { + return $this->getTotalTax() > 0; + } + + public function getHasFees(): bool + { + return $this->getTotalFee() > 0; + } + + public function getLatestInvoice(): ?InvoiceDomainObject + { + return $this->getInvoices()?->sortByDesc(fn(InvoiceDomainObject $invoice) => $invoice->getId())->first(); } public function getStripePayment(): ?StripePaymentDomainObject @@ -158,4 +242,15 @@ public function getEvent(): ?EventDomainObject { return $this->event; } + + public function setInvoices(?Collection $invoices): OrderDomainObject + { + $this->invoices = $invoices; + return $this; + } + + public function getInvoices(): ?Collection + { + return $this->invoices; + } } diff --git a/backend/app/DomainObjects/OrderItemDomainObject.php b/backend/app/DomainObjects/OrderItemDomainObject.php index 1a93d71e59..164b1d9c02 100644 --- a/backend/app/DomainObjects/OrderItemDomainObject.php +++ b/backend/app/DomainObjects/OrderItemDomainObject.php @@ -6,35 +6,49 @@ class OrderItemDomainObject extends Generated\OrderItemDomainObjectAbstract { - private ?TicketPriceDomainObject $ticketPrice = null; + private ?ProductPriceDomainObject $productPrice = null; - public ?TicketDomainObject $ticket = null; + public ?ProductDomainObject $product = null; + + public ?OrderDomainObject $order = null; public function getTotalBeforeDiscount(): float { return Currency::round($this->getPriceBeforeDiscount() * $this->getQuantity()); } - public function getTicketPrice(): ?TicketPriceDomainObject + public function getProductPrice(): ?ProductPriceDomainObject + { + return $this->productPrice; + } + + public function setProductPrice(?ProductPriceDomainObject $tier): self + { + $this->productPrice = $tier; + + return $this; + } + + public function getProduct(): ?ProductDomainObject { - return $this->ticketPrice; + return $this->product; } - public function setTicketPrice(?TicketPriceDomainObject $tier): self + public function setProduct(?ProductDomainObject $product): self { - $this->ticketPrice = $tier; + $this->product = $product; return $this; } - public function getTicket(): ?TicketDomainObject + public function getOrder(): ?OrderDomainObject { - return $this->ticket; + return $this->order; } - public function setTicket(?TicketDomainObject $ticket): self + public function setOrder(?OrderDomainObject $order): self { - $this->ticket = $ticket; + $this->order = $order; return $this; } diff --git a/backend/app/DomainObjects/OrderRefundDomainObject.php b/backend/app/DomainObjects/OrderRefundDomainObject.php new file mode 100644 index 0000000000..3d0a655d25 --- /dev/null +++ b/backend/app/DomainObjects/OrderRefundDomainObject.php @@ -0,0 +1,7 @@ +products = $products; + } + + public function getProducts(): ?Collection + { + return $this->products; + } +} diff --git a/backend/app/DomainObjects/TicketsCheckInListDomainObject.php b/backend/app/DomainObjects/ProductCheckInListDomainObject.php similarity index 54% rename from backend/app/DomainObjects/TicketsCheckInListDomainObject.php rename to backend/app/DomainObjects/ProductCheckInListDomainObject.php index 9e10d8b012..77ef459829 100644 --- a/backend/app/DomainObjects/TicketsCheckInListDomainObject.php +++ b/backend/app/DomainObjects/ProductCheckInListDomainObject.php @@ -2,6 +2,6 @@ namespace HiEvents\DomainObjects; -class TicketsCheckInListDomainObject extends Generated\TicketsCheckInListDomainObjectAbstract +class ProductCheckInListDomainObject extends Generated\ProductCheckInListDomainObjectAbstract { } diff --git a/backend/app/DomainObjects/TicketDomainObject.php b/backend/app/DomainObjects/ProductDomainObject.php similarity index 66% rename from backend/app/DomainObjects/TicketDomainObject.php rename to backend/app/DomainObjects/ProductDomainObject.php index 718a5cc656..c14e3381f3 100644 --- a/backend/app/DomainObjects/TicketDomainObject.php +++ b/backend/app/DomainObjects/ProductDomainObject.php @@ -3,14 +3,14 @@ namespace HiEvents\DomainObjects; use Carbon\Carbon; +use HiEvents\DomainObjects\Enums\ProductPriceType; use HiEvents\Constants; -use HiEvents\DomainObjects\Enums\TicketType; use HiEvents\DomainObjects\Interfaces\IsSortable; use HiEvents\DomainObjects\SortingAndFiltering\AllowedSorts; use Illuminate\Support\Collection; use LogicException; -class TicketDomainObject extends Generated\TicketDomainObjectAbstract implements IsSortable +class ProductDomainObject extends Generated\ProductDomainObjectAbstract implements IsSortable { private ?Collection $taxAndFees = null; @@ -57,7 +57,7 @@ public static function getAllowedSorts(): AllowedSorts ); } - public function setTaxAndFees(Collection $taxes): TicketDomainObject + public function setTaxAndFees(Collection $taxes): ProductDomainObject { $this->taxAndFees = $taxes; return $this; @@ -80,36 +80,36 @@ public function getFees(): ?Collection public function isSoldOut(): bool { - if (!$this->getTicketPrices() || $this->getTicketPrices()->isEmpty()) { + if (!$this->getProductPrices() || $this->getProductPrices()->isEmpty()) { return true; } - return $this->getTicketPrices()->every(fn(TicketPriceDomainObject $price) => $price->isSoldOut()); + return $this->getProductPrices()->every(fn(ProductPriceDomainObject $price) => $price->isSoldOut()); } public function getQuantityAvailable(): int { - $availableCount = $this->getTicketPrices()->sum(fn(TicketPriceDomainObject $price) => $price->getQuantityAvailable()); + $availableCount = $this->getProductPrices()->sum(fn(ProductPriceDomainObject $price) => $price->getQuantityAvailable()); if ($this->quantityAvailable !== null) { return min($availableCount, $this->quantityAvailable); } - if (!$this->getTicketPrices() || $this->getTicketPrices()->isEmpty()) { + if (!$this->getProductPrices() || $this->getProductPrices()->isEmpty()) { return 0; } // This is to address a case where prices have an unlimited quantity available and the user has // enabled show_quantity_remaining. if ($this->getShowQuantityRemaining() - && $this->getTicketPrices()->first(fn(TicketPriceDomainObject $price) => $price->getQuantityAvailable() === null)) { + && $this->getProductPrices()->first(fn(ProductPriceDomainObject $price) => $price->getQuantityAvailable() === null)) { return Constants::INFINITE; } return $availableCount; } - public function setQuantityAvailable(int $quantityAvailable): TicketDomainObject + public function setQuantityAvailable(int $quantityAvailable): ProductDomainObject { $this->quantityAvailable = $quantityAvailable; @@ -133,7 +133,7 @@ public function isAfterSaleEndDate(): bool public function isAvailable(): bool { // If all prices are hidden, it's not available - if ($this->getType() === TicketType::TIERED->name && $this->getTicketPrices()?->isEmpty()) { + if ($this->getType() === ProductPriceType::TIERED->name && $this->getProductPrices()?->isEmpty()) { return false; } @@ -144,14 +144,14 @@ public function isAvailable(): bool } /** - * @return Collection|null + * @return Collection|null */ - public function getTicketPrices(): ?Collection + public function getProductPrices(): ?Collection { return $this->prices; } - public function setTicketPrices(?Collection $prices): self + public function setProductPrices(?Collection $prices): self { $this->prices = $prices; @@ -159,59 +159,59 @@ public function setTicketPrices(?Collection $prices): self } /** - * All ticket types except TIERED have a single price, so we can just return the first price. + * All product types except TIERED have a single price, so we can just return the first price. * * @return float|null */ public function getPrice(): ?float { - if ($this->getType() === TicketType::TIERED->name) { - throw new LogicException('You cannot get a single price for a tiered ticket. Use getPrices() instead.'); + if ($this->getType() === ProductPriceType::TIERED->name) { + throw new LogicException('You cannot get a single price for a tiered product. Use getPrices() instead.'); } - return $this->getTicketPrices()?->first()->getPrice(); + return $this->getProductPrices()?->first()?->getPrice(); } - public function getPriceById(int $priceId): ?TicketPriceDomainObject + public function getPriceById(int $priceId): ?ProductPriceDomainObject { - return $this->getTicketPrices()?->first(fn(TicketPriceDomainObject $price) => $price->getId() === $priceId); + return $this->getProductPrices()?->first(fn(ProductPriceDomainObject $price) => $price->getId() === $priceId); } public function isTieredType(): bool { - return $this->getType() === TicketType::TIERED->name; + return $this->getType() === ProductPriceType::TIERED->name; } public function isDonationType(): bool { - return $this->getType() === TicketType::DONATION->name; + return $this->getType() === ProductPriceType::DONATION->name; } public function isPaidType(): bool { - return $this->getType() === TicketType::PAID->name; + return $this->getType() === ProductPriceType::PAID->name; } public function isFreeType(): bool { - return $this->getType() === TicketType::FREE->name; + return $this->getType() === ProductPriceType::FREE->name; } public function getInitialQuantityAvailable(): ?int { - if ($this->getType() === TicketType::TIERED->name) { - return $this->getTicketPrices()?->sum(fn(TicketPriceDomainObject $price) => $price->getInitialQuantityAvailable()); + if ($this->getType() === ProductPriceType::TIERED->name) { + return $this->getProductPrices()?->sum(fn(ProductPriceDomainObject $price) => $price->getInitialQuantityAvailable()); } - return $this->getTicketPrices()?->first()?->getInitialQuantityAvailable(); + return $this->getProductPrices()?->first()?->getInitialQuantityAvailable(); } public function getQuantitySold(): int { - return $this->getTicketPrices()?->sum(fn(TicketPriceDomainObject $price) => $price->getQuantitySold()) ?? 0; + return $this->getProductPrices()?->sum(fn(ProductPriceDomainObject $price) => $price->getQuantitySold()) ?? 0; } - public function setOffSaleReason(?string $offSaleReason): TicketDomainObject + public function setOffSaleReason(?string $offSaleReason): ProductDomainObject { $this->offSaleReason = $offSaleReason; diff --git a/backend/app/DomainObjects/TicketPriceDomainObject.php b/backend/app/DomainObjects/ProductPriceDomainObject.php similarity index 89% rename from backend/app/DomainObjects/TicketPriceDomainObject.php rename to backend/app/DomainObjects/ProductPriceDomainObject.php index d2d312e96f..0919781ec1 100644 --- a/backend/app/DomainObjects/TicketPriceDomainObject.php +++ b/backend/app/DomainObjects/ProductPriceDomainObject.php @@ -6,7 +6,7 @@ use HiEvents\Helper\Currency; use LogicException; -class TicketPriceDomainObject extends Generated\TicketPriceDomainObjectAbstract +class ProductPriceDomainObject extends Generated\ProductPriceDomainObjectAbstract { private ?float $priceBeforeDiscount = null; @@ -23,7 +23,7 @@ public function getPriceBeforeDiscount(): ?float return $this->priceBeforeDiscount; } - public function setPriceBeforeDiscount(?float $originalPrice): TicketPriceDomainObject + public function setPriceBeforeDiscount(?float $originalPrice): ProductPriceDomainObject { $this->priceBeforeDiscount = $originalPrice; @@ -96,13 +96,13 @@ public function isAvailable(): ?bool return $this->isAvailable; } - public function setIsAvailable(?bool $isAvailable): TicketPriceDomainObject + public function setIsAvailable(?bool $isAvailable): ProductPriceDomainObject { $this->isAvailable = $isAvailable; return $this; } - public function setOffSaleReason(?string $offSaleReason): TicketPriceDomainObject + public function setOffSaleReason(?string $offSaleReason): ProductPriceDomainObject { $this->offSaleReason = $offSaleReason; diff --git a/backend/app/DomainObjects/ProductQuestionDomainObject.php b/backend/app/DomainObjects/ProductQuestionDomainObject.php new file mode 100644 index 0000000000..866ff446c3 --- /dev/null +++ b/backend/app/DomainObjects/ProductQuestionDomainObject.php @@ -0,0 +1,7 @@ +getApplicableTicketIds()) { + // If there's no product IDs we apply the promo to all products + if (!$this->getApplicableProductIds()) { return true; } - return in_array($ticket->getId(), array_map('intval', $this->getApplicableTicketIds()), true); + return in_array($product->getId(), array_map('intval', $this->getApplicableProductIds()), true); } public function isFixedDiscount(): bool diff --git a/backend/app/DomainObjects/QuestionAndAnswerViewDomainObject.php b/backend/app/DomainObjects/QuestionAndAnswerViewDomainObject.php index b0af866487..503b3e893e 100644 --- a/backend/app/DomainObjects/QuestionAndAnswerViewDomainObject.php +++ b/backend/app/DomainObjects/QuestionAndAnswerViewDomainObject.php @@ -10,6 +10,8 @@ class QuestionAndAnswerViewDomainObject extends AbstractDomainObject final public const SINGULAR_NAME = 'question_and_answer_view'; final public const PLURAL_NAME = 'question_and_answer_views'; + private ?int $product_id; + private ?string $product_title; private int $question_id; private ?int $order_id; private string $title; @@ -21,6 +23,10 @@ class QuestionAndAnswerViewDomainObject extends AbstractDomainObject private string $question_type; private int $event_id; + private ?AttendeeDomainObject $attendee = null; + + private ?QuestionDomainObject $question = null; + public function getQuestionId(): int { return $this->question_id; @@ -131,6 +137,52 @@ public function setEventId(int $event_id): QuestionAndAnswerViewDomainObject return $this; } + public function getProductId(): ?int + { + return $this->product_id; + } + + public function setProductId(?int $product_id): QuestionAndAnswerViewDomainObject + { + $this->product_id = $product_id; + return $this; + } + + public function getProductTitle(): ?string + { + return $this->product_title; + } + + public function setProductTitle(?string $product_title): QuestionAndAnswerViewDomainObject + { + $this->product_title = $product_title; + return $this; + } + + public function getAttendee(): ?AttendeeDomainObject + { + return $this->attendee; + } + + public function setAttendee(?AttendeeDomainObject $attendee): static + { + $this->attendee = $attendee; + + return $this; + } + + public function getQuestion(): ?QuestionDomainObject + { + return $this->question; + } + + public function setQuestion(?QuestionDomainObject $question): static + { + $this->question = $question; + + return $this; + } + public function toArray(): array { return [ diff --git a/backend/app/DomainObjects/QuestionDomainObject.php b/backend/app/DomainObjects/QuestionDomainObject.php index 6ddd5a1331..5b544cc98d 100644 --- a/backend/app/DomainObjects/QuestionDomainObject.php +++ b/backend/app/DomainObjects/QuestionDomainObject.php @@ -7,24 +7,26 @@ class QuestionDomainObject extends Generated\QuestionDomainObjectAbstract { - public ?Collection $tickets = null; + public ?Collection $products = null; - public function setTickets(?Collection $tickets): QuestionDomainObject + public function setProducts(?Collection $products): QuestionDomainObject { - $this->tickets = $tickets; + $this->products = $products; return $this; } - public function getTickets(): ?Collection + public function getProducts(): ?Collection { - return $this->tickets; + return $this->products; } - public function isMultipleChoice(): bool + public function isPreDefinedChoice(): bool { return in_array($this->getType(), [ - QuestionTypeEnum::MULTI_SELECT_DROPDOWN, - QuestionTypeEnum::CHECKBOX, + QuestionTypeEnum::MULTI_SELECT_DROPDOWN->name, + QuestionTypeEnum::CHECKBOX->name, + QuestionTypeEnum::RADIO->name, + QuestionTypeEnum::DROPDOWN->name, ], true); } diff --git a/backend/app/DomainObjects/Status/AttendeeStatus.php b/backend/app/DomainObjects/Status/AttendeeStatus.php index a41f3bb804..1b668a93f3 100644 --- a/backend/app/DomainObjects/Status/AttendeeStatus.php +++ b/backend/app/DomainObjects/Status/AttendeeStatus.php @@ -9,5 +9,6 @@ enum AttendeeStatus use BaseEnum; case ACTIVE; + case AWAITING_PAYMENT; case CANCELLED; } diff --git a/backend/app/DomainObjects/Status/InvoiceStatus.php b/backend/app/DomainObjects/Status/InvoiceStatus.php new file mode 100644 index 0000000000..1395a266ef --- /dev/null +++ b/backend/app/DomainObjects/Status/InvoiceStatus.php @@ -0,0 +1,14 @@ +name => __('Reserved'), + self::CANCELLED->name => __('Cancelled'), + self::COMPLETED->name => __('Completed'), + self::AWAITING_OFFLINE_PAYMENT->name => __('Awaiting offline payment'), + }; + } } diff --git a/backend/app/DomainObjects/Status/TicketStatus.php b/backend/app/DomainObjects/Status/ProductStatus.php similarity index 88% rename from backend/app/DomainObjects/Status/TicketStatus.php rename to backend/app/DomainObjects/Status/ProductStatus.php index 2f6e2bfa8c..fe55a34752 100644 --- a/backend/app/DomainObjects/Status/TicketStatus.php +++ b/backend/app/DomainObjects/Status/ProductStatus.php @@ -4,7 +4,7 @@ use HiEvents\DomainObjects\Enums\BaseEnum; -enum TicketStatus +enum ProductStatus { use BaseEnum; diff --git a/backend/app/DomainObjects/Status/WebhookStatus.php b/backend/app/DomainObjects/Status/WebhookStatus.php new file mode 100644 index 0000000000..6a15e20385 --- /dev/null +++ b/backend/app/DomainObjects/Status/WebhookStatus.php @@ -0,0 +1,13 @@ +logs = $logs; + return $this; + } + + public function getLogs(): ?Collection + { + return $this->logs; + } +} diff --git a/backend/app/DomainObjects/WebhookLogDomainObject.php b/backend/app/DomainObjects/WebhookLogDomainObject.php new file mode 100644 index 0000000000..2a83d3b31e --- /dev/null +++ b/backend/app/DomainObjects/WebhookLogDomainObject.php @@ -0,0 +1,7 @@ +questions->map(fn($question) => $question->getTitle())->toArray(); return array_merge([ - 'ID', - 'First Name', - 'Last Name', - 'Email', - 'Status', - 'Is Checked In', - 'Checked In At', - 'Ticket ID', - 'Ticket Name', - 'Event ID', - 'Public ID', - 'Short ID', - 'Created Date', - 'Last Updated Date' + __('ID'), + __('First Name'), + __('Last Name'), + __('Email'), + __('Status'), + __('Is Checked In'), + __('Checked In At'), + __('Product ID'), + __('Product Name'), + __('Event ID'), + __('Public ID'), + __('Short ID'), + __('Created Date'), + __('Last Updated Date'), + __('Notes'), ], $questionTitles); } @@ -79,13 +80,13 @@ public function map($attendee): array ); }); - /** @var TicketDomainObject $ticket */ - $ticket = $attendee->getTicket(); + /** @var ProductDomainObject $ticket */ + $ticket = $attendee->getProduct(); $ticketName = $ticket->getTitle(); - if ($attendee->getTicket()?->getType() === TicketType::TIERED->name) { + if ($ticket->getType() === ProductPriceType::TIERED->name) { $ticketName .= ' - ' . $ticket - ->getTicketPrices() - ->first(fn(TicketPriceDomainObject $tp) => $tp->getId() === $attendee->getTicketPriceId()) + ->getProductPrices() + ->first(fn(ProductPriceDomainObject $tp) => $tp->getId() === $attendee->getProductPriceId()) ->getLabel(); } @@ -99,13 +100,14 @@ public function map($attendee): array $attendee->getCheckIn() ? Carbon::parse($attendee->getCheckIn()->getCreatedAt())->format('Y-m-d H:i:s') : '', - $attendee->getTicketId(), + $attendee->getProductId(), $ticketName, $attendee->getEventId(), $attendee->getPublicId(), $attendee->getShortId(), Carbon::parse($attendee->getCreatedAt())->format('Y-m-d H:i:s'), Carbon::parse($attendee->getUpdatedAt())->format('Y-m-d H:i:s'), + $attendee->getNotes(), ], $answers->toArray()); } diff --git a/backend/app/Exports/OrdersExport.php b/backend/app/Exports/OrdersExport.php index 0073b2b2b9..ea34a35a2d 100644 --- a/backend/app/Exports/OrdersExport.php +++ b/backend/app/Exports/OrdersExport.php @@ -43,26 +43,29 @@ public function headings(): array $questionTitles = $this->questions->map(fn($question) => $question->getTitle())->toArray(); return array_merge([ - 'ID', - 'First Name', - 'Last Name', - 'Email', - 'Total Before Additions', - 'Total Gross', - 'Total Tax', - 'Total Fee', - 'Total Refunded', - 'Status', - 'Payment Status', - 'Refund Status', - 'Currency', - 'Created At', - 'Public ID', - 'Payment Gateway', - 'Is Partially Refunded', - 'Is Fully Refunded', - 'Is Free Order', - 'Is Manually Created', + __('ID'), + __('First Name'), + __('Last Name'), + __('Email'), + __('Total Before Additions'), + __('Total Gross'), + __('Total Tax'), + __('Total Fee'), + __('Total Refunded'), + __('Status'), + __('Payment Status'), + __('Refund Status'), + __('Currency'), + __('Created At'), + __('Public ID'), + __('Payment Gateway'), + __('Is Partially Refunded'), + __('Is Fully Refunded'), + __('Is Free Order'), + __('Is Manually Created'), + __('Billing Address'), + __('Notes'), + __('Promo Code'), ], $questionTitles); } @@ -103,6 +106,9 @@ public function map($order): array $order->isFullyRefunded(), $order->isFreeOrder(), $order->getIsManuallyCreated(), + $order->getBillingAddressString(), + $order->getNotes(), + $order->getPromoCode(), ], $answers->toArray()); } diff --git a/backend/app/Helper/AddressHelper.php b/backend/app/Helper/AddressHelper.php new file mode 100644 index 0000000000..3710426cb9 --- /dev/null +++ b/backend/app/Helper/AddressHelper.php @@ -0,0 +1,28 @@ + !is_null($part) && $part !== ''); + + return implode(', ', $filteredAddressParts); + } +} + diff --git a/backend/app/Helper/Url.php b/backend/app/Helper/Url.php index 4eb96a6e5a..eb9c8bd0a6 100644 --- a/backend/app/Helper/Url.php +++ b/backend/app/Helper/Url.php @@ -11,7 +11,7 @@ class Url public const ACCEPT_INVITATION = 'app.frontend_urls.accept_invitation'; public const CONFIRM_EMAIL_ADDRESS = 'app.frontend_urls.confirm_email_address'; public const EVENT_HOMEPAGE = 'app.frontend_urls.event_homepage'; - public const ATTENDEE_TICKET = 'app.frontend_urls.attendee_ticket'; + public const ATTENDEE_TICKET = 'app.frontend_urls.attendee_product'; public const ORDER_SUMMARY = 'app.frontend_urls.order_summary'; public const ORGANIZER_ORDER_SUMMARY = 'app.frontend_urls.organizer_order_summary'; @@ -22,6 +22,13 @@ public static function getFrontEndUrlFromConfig(string $key, array $queryParams return self::addQueryParamsToUrl($queryParams, $url); } + public static function getApiUrl(string $path, array $queryParams = []): string + { + $url = rtrim(config('app.api_url'), '/') . '/' . ltrim($path, '/'); + + return self::addQueryParamsToUrl($queryParams, $url); + } + public static function getCdnUrl(string $path): string { return config('app.cnd_url') . '/' . $path; diff --git a/backend/app/Http/Actions/Accounts/CreateAccountAction.php b/backend/app/Http/Actions/Accounts/CreateAccountAction.php index 26f94ea0ab..f5c6fbda00 100644 --- a/backend/app/Http/Actions/Accounts/CreateAccountAction.php +++ b/backend/app/Http/Actions/Accounts/CreateAccountAction.php @@ -10,12 +10,13 @@ use HiEvents\Http\Request\Account\CreateAccountRequest; use HiEvents\Http\ResponseCodes; use HiEvents\Resources\Account\AccountResource; +use HiEvents\Services\Application\Handlers\Account\CreateAccountHandler; +use HiEvents\Services\Application\Handlers\Account\DTO\CreateAccountDTO; +use HiEvents\Services\Application\Handlers\Account\Exceptions\AccountConfigurationDoesNotExist; +use HiEvents\Services\Application\Handlers\Account\Exceptions\AccountRegistrationDisabledException; +use HiEvents\Services\Application\Handlers\Auth\DTO\LoginCredentialsDTO; +use HiEvents\Services\Application\Handlers\Auth\LoginHandler; use HiEvents\Services\Application\Locale\LocaleService; -use HiEvents\Services\Handlers\Account\CreateAccountHandler; -use HiEvents\Services\Handlers\Account\DTO\CreateAccountDTO; -use HiEvents\Services\Handlers\Account\Exceptions\AccountRegistrationDisabledException; -use HiEvents\Services\Handlers\Auth\DTO\LoginCredentialsDTO; -use HiEvents\Services\Handlers\Auth\LoginHandler; use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; use Throwable; @@ -47,6 +48,7 @@ public function __invoke(CreateAccountRequest $request): JsonResponse 'locale' => $request->has('locale') ? $request->validated('locale') : $this->localeService->getLocaleOrDefault($request->getPreferredLanguage()), + 'invite_token' => $request->validated('invite_token'), ])); } catch (EmailAlreadyExists $e) { throw ValidationException::withMessages([ @@ -57,6 +59,11 @@ public function __invoke(CreateAccountRequest $request): JsonResponse message: __('Account registration is disabled'), statusCode: ResponseCodes::HTTP_FORBIDDEN, ); + } catch (AccountConfigurationDoesNotExist $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: ResponseCodes::HTTP_INTERNAL_SERVER_ERROR, + ); } try { diff --git a/backend/app/Http/Actions/Accounts/GetAccountAction.php b/backend/app/Http/Actions/Accounts/GetAccountAction.php index 26f0280632..e24caf412a 100644 --- a/backend/app/Http/Actions/Accounts/GetAccountAction.php +++ b/backend/app/Http/Actions/Accounts/GetAccountAction.php @@ -4,8 +4,10 @@ namespace HiEvents\Http\Actions\Accounts; +use HiEvents\DomainObjects\AccountConfigurationDomainObject; 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; @@ -23,7 +25,12 @@ public function __invoke(?int $accountId = null): JsonResponse { $this->minimumAllowedRole(Role::ORGANIZER); - $account = $this->accountRepository->findById($this->getAuthenticatedAccountId()); + $account = $this->accountRepository + ->loadRelation(new Relationship( + domainObject: AccountConfigurationDomainObject::class, + name: 'configuration', + )) + ->findById($this->getAuthenticatedAccountId()); return $this->resourceResponse(AccountResource::class, $account); } diff --git a/backend/app/Http/Actions/Accounts/Stripe/CreateStripeConnectAccountAction.php b/backend/app/Http/Actions/Accounts/Stripe/CreateStripeConnectAccountAction.php index 5018f8e58d..4da827190d 100644 --- a/backend/app/Http/Actions/Accounts/Stripe/CreateStripeConnectAccountAction.php +++ b/backend/app/Http/Actions/Accounts/Stripe/CreateStripeConnectAccountAction.php @@ -9,8 +9,8 @@ use HiEvents\Exceptions\SaasModeEnabledException; use HiEvents\Http\Actions\BaseAction; use HiEvents\Resources\Account\Stripe\StripeConnectAccountResponseResource; -use HiEvents\Services\Handlers\Account\Payment\Stripe\CreateStripeConnectAccountHandler; -use HiEvents\Services\Handlers\Account\Payment\Stripe\DTO\CreateStripeConnectAccountDTO; +use HiEvents\Services\Application\Handlers\Account\Payment\Stripe\CreateStripeConnectAccountHandler; +use HiEvents\Services\Application\Handlers\Account\Payment\Stripe\DTO\CreateStripeConnectAccountDTO; use Illuminate\Http\JsonResponse; use Symfony\Component\HttpFoundation\Response; use Throwable; @@ -32,7 +32,7 @@ public function __invoke(int $accountId): JsonResponse try { $accountResult = $this->createStripeConnectAccountHandler->handle(CreateStripeConnectAccountDTO::fromArray([ - 'accountId' => $accountId, + 'accountId' => $this->getAuthenticatedAccountId(), ])); } catch (CreateStripeConnectAccountLinksFailedException|CreateStripeConnectAccountFailedException $e) { return $this->errorResponse( diff --git a/backend/app/Http/Actions/Accounts/UpdateAccountAction.php b/backend/app/Http/Actions/Accounts/UpdateAccountAction.php index 082281f9ca..a4fd204025 100644 --- a/backend/app/Http/Actions/Accounts/UpdateAccountAction.php +++ b/backend/app/Http/Actions/Accounts/UpdateAccountAction.php @@ -6,8 +6,8 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\Account\UpdateAccountRequest; use HiEvents\Resources\Account\AccountResource; -use HiEvents\Services\Handlers\Account\DTO\UpdateAccountDTO; -use HiEvents\Services\Handlers\Account\UpdateAccountHanlder; +use HiEvents\Services\Application\Handlers\Account\DTO\UpdateAccountDTO; +use HiEvents\Services\Application\Handlers\Account\UpdateAccountHanlder; use Illuminate\Http\JsonResponse; class UpdateAccountAction extends BaseAction diff --git a/backend/app/Http/Actions/Attendees/CheckInAttendeeAction.php b/backend/app/Http/Actions/Attendees/CheckInAttendeeAction.php index f5f6ece690..2717865eb5 100644 --- a/backend/app/Http/Actions/Attendees/CheckInAttendeeAction.php +++ b/backend/app/Http/Actions/Attendees/CheckInAttendeeAction.php @@ -8,8 +8,8 @@ use HiEvents\Http\Request\Attendee\CheckInAttendeeRequest; use HiEvents\Http\ResponseCodes; use HiEvents\Resources\Attendee\AttendeeResource; -use HiEvents\Services\Handlers\Attendee\CheckInAttendeeHandler; -use HiEvents\Services\Handlers\Attendee\DTO\CheckInAttendeeDTO; +use HiEvents\Services\Application\Handlers\Attendee\CheckInAttendeeHandler; +use HiEvents\Services\Application\Handlers\Attendee\DTO\CheckInAttendeeDTO; use Illuminate\Http\JsonResponse; class CheckInAttendeeAction extends BaseAction diff --git a/backend/app/Http/Actions/Attendees/CreateAttendeeAction.php b/backend/app/Http/Actions/Attendees/CreateAttendeeAction.php index e43a97761b..492b065108 100644 --- a/backend/app/Http/Actions/Attendees/CreateAttendeeAction.php +++ b/backend/app/Http/Actions/Attendees/CreateAttendeeAction.php @@ -3,14 +3,14 @@ namespace HiEvents\Http\Actions\Attendees; use HiEvents\DomainObjects\EventDomainObject; -use HiEvents\Exceptions\InvalidTicketPriceId; +use HiEvents\Exceptions\InvalidProductPriceId; use HiEvents\Exceptions\NoTicketsAvailableException; use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\Attendee\CreateAttendeeRequest; use HiEvents\Http\ResponseCodes; use HiEvents\Resources\Attendee\AttendeeResource; -use HiEvents\Services\Handlers\Attendee\CreateAttendeeHandler; -use HiEvents\Services\Handlers\Attendee\DTO\CreateAttendeeDTO; +use HiEvents\Services\Application\Handlers\Attendee\CreateAttendeeHandler; +use HiEvents\Services\Application\Handlers\Attendee\DTO\CreateAttendeeDTO; use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; use Throwable; @@ -39,11 +39,11 @@ public function __invoke(CreateAttendeeRequest $request, int $eventId): JsonResp )); } catch (NoTicketsAvailableException $exception) { throw ValidationException::withMessages([ - 'ticket_id' => $exception->getMessage(), + 'product_id' => $exception->getMessage(), ]); - } catch (InvalidTicketPriceId $exception) { + } catch (InvalidProductPriceId $exception) { throw ValidationException::withMessages([ - 'ticket_price_id' => $exception->getMessage(), + 'product_price_id' => $exception->getMessage(), ]); } diff --git a/backend/app/Http/Actions/Attendees/EditAttendeeAction.php b/backend/app/Http/Actions/Attendees/EditAttendeeAction.php index c9f11f6d40..ede204d4fc 100644 --- a/backend/app/Http/Actions/Attendees/EditAttendeeAction.php +++ b/backend/app/Http/Actions/Attendees/EditAttendeeAction.php @@ -7,8 +7,8 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\Attendee\EditAttendeeRequest; use HiEvents\Resources\Attendee\AttendeeResource; -use HiEvents\Services\Handlers\Attendee\DTO\EditAttendeeDTO; -use HiEvents\Services\Handlers\Attendee\EditAttendeeHandler; +use HiEvents\Services\Application\Handlers\Attendee\DTO\EditAttendeeDTO; +use HiEvents\Services\Application\Handlers\Attendee\EditAttendeeHandler; use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; use Throwable; @@ -33,14 +33,15 @@ public function __invoke(EditAttendeeRequest $request, int $eventId, int $attend 'first_name' => $request->input('first_name'), 'last_name' => $request->input('last_name'), 'email' => $request->input('email'), - 'ticket_id' => $request->input('ticket_id'), - 'ticket_price_id' => $request->input('ticket_price_id'), + 'product_id' => $request->input('product_id'), + 'product_price_id' => $request->input('product_price_id'), 'event_id' => $eventId, 'attendee_id' => $attendeeId, + 'notes' => $request->input('notes'), ])); } catch (NoTicketsAvailableException $exception) { throw ValidationException::withMessages([ - 'ticket_id' => $exception->getMessage(), + 'product_id' => $exception->getMessage(), ]); } diff --git a/backend/app/Http/Actions/Attendees/ExportAttendeesAction.php b/backend/app/Http/Actions/Attendees/ExportAttendeesAction.php index 729b5c8a59..65789f6a61 100644 --- a/backend/app/Http/Actions/Attendees/ExportAttendeesAction.php +++ b/backend/app/Http/Actions/Attendees/ExportAttendeesAction.php @@ -5,9 +5,9 @@ use HiEvents\DomainObjects\AttendeeCheckInDomainObject; use HiEvents\DomainObjects\Enums\QuestionBelongsTo; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\ProductDomainObject; +use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\DomainObjects\QuestionAndAnswerViewDomainObject; -use HiEvents\DomainObjects\TicketDomainObject; -use HiEvents\DomainObjects\TicketPriceDomainObject; use HiEvents\Exports\AttendeesExport; use HiEvents\Http\Actions\BaseAction; use HiEvents\Repository\Eloquent\Value\Relationship; @@ -40,19 +40,19 @@ public function __invoke(int $eventId): BinaryFileResponse name: 'check_in', )) ->loadRelation(new Relationship( - domainObject: TicketDomainObject::class, + domainObject: ProductDomainObject::class, nested: [ new Relationship( - domainObject: TicketPriceDomainObject::class, + domainObject: ProductPriceDomainObject::class, ), ], - name: 'ticket' + name: 'product' )) ->findByEventIdForExport($eventId); $questions = $this->questionRepository->findWhere([ 'event_id' => $eventId, - 'belongs_to' => QuestionBelongsTo::TICKET->name, + 'belongs_to' => QuestionBelongsTo::PRODUCT->name, ]); return Excel::download( diff --git a/backend/app/Http/Actions/Attendees/GetAttendeeAction.php b/backend/app/Http/Actions/Attendees/GetAttendeeAction.php index f022a73499..fdf2a2107b 100644 --- a/backend/app/Http/Actions/Attendees/GetAttendeeAction.php +++ b/backend/app/Http/Actions/Attendees/GetAttendeeAction.php @@ -4,9 +4,9 @@ use HiEvents\DomainObjects\AttendeeCheckInDomainObject; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\ProductDomainObject; +use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\DomainObjects\QuestionAndAnswerViewDomainObject; -use HiEvents\DomainObjects\TicketDomainObject; -use HiEvents\DomainObjects\TicketPriceDomainObject; use HiEvents\Http\Actions\BaseAction; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; @@ -30,12 +30,12 @@ public function __invoke(int $eventId, int $attendeeId): Response|JsonResponse $attendee = $this->attendeeRepository ->loadRelation(relationship: QuestionAndAnswerViewDomainObject::class) ->loadRelation(new Relationship( - domainObject: TicketDomainObject::class, + domainObject: ProductDomainObject::class, nested: [ new Relationship( - domainObject: TicketPriceDomainObject::class, + domainObject: ProductPriceDomainObject::class, ), - ], name: 'ticket')) + ], name: 'product')) ->loadRelation(new Relationship( domainObject: AttendeeCheckInDomainObject::class, name: 'check_in', diff --git a/backend/app/Http/Actions/Attendees/GetAttendeeActionPublic.php b/backend/app/Http/Actions/Attendees/GetAttendeeActionPublic.php index 87d0b2f88c..5be7ddf396 100644 --- a/backend/app/Http/Actions/Attendees/GetAttendeeActionPublic.php +++ b/backend/app/Http/Actions/Attendees/GetAttendeeActionPublic.php @@ -3,8 +3,8 @@ namespace HiEvents\Http\Actions\Attendees; use HiEvents\DomainObjects\Generated\AttendeeDomainObjectAbstract; -use HiEvents\DomainObjects\TicketDomainObject; -use HiEvents\DomainObjects\TicketPriceDomainObject; +use HiEvents\DomainObjects\ProductDomainObject; +use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\Http\Actions\BaseAction; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; @@ -28,12 +28,12 @@ public function __invoke(int $eventId, string $attendeeShortId): JsonResponse|Re { $attendee = $this->attendeeRepository ->loadRelation(new Relationship( - domainObject: TicketDomainObject::class, + domainObject: ProductDomainObject::class, nested: [ new Relationship( - domainObject: TicketPriceDomainObject::class, + domainObject: ProductPriceDomainObject::class, ), - ], name: 'ticket')) + ], name: 'product')) ->findFirstWhere([ AttendeeDomainObjectAbstract::SHORT_ID => $attendeeShortId ]); diff --git a/backend/app/Http/Actions/Attendees/PartialEditAttendeeAction.php b/backend/app/Http/Actions/Attendees/PartialEditAttendeeAction.php index 16d308a428..e62d0dbd00 100644 --- a/backend/app/Http/Actions/Attendees/PartialEditAttendeeAction.php +++ b/backend/app/Http/Actions/Attendees/PartialEditAttendeeAction.php @@ -6,8 +6,8 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\Attendee\PartialEditAttendeeRequest; use HiEvents\Resources\Attendee\AttendeeResource; -use HiEvents\Services\Handlers\Attendee\DTO\PartialEditAttendeeDTO; -use HiEvents\Services\Handlers\Attendee\PartialEditAttendeeHandler; +use HiEvents\Services\Application\Handlers\Attendee\DTO\PartialEditAttendeeDTO; +use HiEvents\Services\Application\Handlers\Attendee\PartialEditAttendeeHandler; use Illuminate\Http\JsonResponse; class PartialEditAttendeeAction extends BaseAction diff --git a/backend/app/Http/Actions/Attendees/ResendAttendeeTicketAction.php b/backend/app/Http/Actions/Attendees/ResendAttendeeTicketAction.php index 9a6c129570..191fdb8891 100644 --- a/backend/app/Http/Actions/Attendees/ResendAttendeeTicketAction.php +++ b/backend/app/Http/Actions/Attendees/ResendAttendeeTicketAction.php @@ -4,8 +4,8 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Services\Handlers\Attendee\DTO\ResendAttendeeTicketDTO; -use HiEvents\Services\Handlers\Attendee\ResendAttendeeTicketHandler; +use HiEvents\Services\Application\Handlers\Attendee\DTO\ResendAttendeeTicketDTO; +use HiEvents\Services\Application\Handlers\Attendee\ResendAttendeeTicketHandler; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; use Symfony\Component\Routing\Exception\ResourceNotFoundException; diff --git a/backend/app/Http/Actions/Auth/AcceptInvitationAction.php b/backend/app/Http/Actions/Auth/AcceptInvitationAction.php index 90d2f60684..ca147ee92b 100644 --- a/backend/app/Http/Actions/Auth/AcceptInvitationAction.php +++ b/backend/app/Http/Actions/Auth/AcceptInvitationAction.php @@ -6,8 +6,8 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\Auth\AcceptInvitationRequest; use HiEvents\Http\ResponseCodes; -use HiEvents\Services\Handlers\Auth\AcceptInvitationHandler; -use HiEvents\Services\Handlers\Auth\DTO\AcceptInvitationDTO; +use HiEvents\Services\Application\Handlers\Auth\AcceptInvitationHandler; +use HiEvents\Services\Application\Handlers\Auth\DTO\AcceptInvitationDTO; use HiEvents\Services\Infrastructure\Encryption\Exception\DecryptionFailedException; use HiEvents\Services\Infrastructure\Encryption\Exception\EncryptedPayloadExpiredException; use Illuminate\Http\Response; diff --git a/backend/app/Http/Actions/Auth/BaseAuthAction.php b/backend/app/Http/Actions/Auth/BaseAuthAction.php index d11699a1e7..e5c5ecace7 100644 --- a/backend/app/Http/Actions/Auth/BaseAuthAction.php +++ b/backend/app/Http/Actions/Auth/BaseAuthAction.php @@ -4,7 +4,7 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Resources\Auth\AuthenticatedResponseResource; -use HiEvents\Services\Handlers\Auth\DTO\AuthenticatedResponseDTO; +use HiEvents\Services\Application\Handlers\Auth\DTO\AuthenticatedResponseDTO; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; use Illuminate\Support\Collection; diff --git a/backend/app/Http/Actions/Auth/ForgotPasswordAction.php b/backend/app/Http/Actions/Auth/ForgotPasswordAction.php index 693cbdab2c..e272b2ce37 100644 --- a/backend/app/Http/Actions/Auth/ForgotPasswordAction.php +++ b/backend/app/Http/Actions/Auth/ForgotPasswordAction.php @@ -4,7 +4,7 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\Auth\ForgotPasswordRequest; -use HiEvents\Services\Handlers\Auth\ForgotPasswordHandler; +use HiEvents\Services\Application\Handlers\Auth\ForgotPasswordHandler; use Illuminate\Http\JsonResponse; use Symfony\Component\Routing\Exception\ResourceNotFoundException; diff --git a/backend/app/Http/Actions/Auth/LoginAction.php b/backend/app/Http/Actions/Auth/LoginAction.php index eee4960a39..e5f3b1782f 100644 --- a/backend/app/Http/Actions/Auth/LoginAction.php +++ b/backend/app/Http/Actions/Auth/LoginAction.php @@ -7,8 +7,8 @@ use HiEvents\Exceptions\UnauthorizedException; use HiEvents\Http\Request\Auth\LoginRequest; use HiEvents\Http\ResponseCodes; -use HiEvents\Services\Handlers\Auth\DTO\LoginCredentialsDTO; -use HiEvents\Services\Handlers\Auth\LoginHandler; +use HiEvents\Services\Application\Handlers\Auth\DTO\LoginCredentialsDTO; +use HiEvents\Services\Application\Handlers\Auth\LoginHandler; use Illuminate\Http\JsonResponse; class LoginAction extends BaseAuthAction diff --git a/backend/app/Http/Actions/Auth/ResetPasswordAction.php b/backend/app/Http/Actions/Auth/ResetPasswordAction.php index d7425d336e..6e7b25d891 100644 --- a/backend/app/Http/Actions/Auth/ResetPasswordAction.php +++ b/backend/app/Http/Actions/Auth/ResetPasswordAction.php @@ -5,8 +5,8 @@ use HiEvents\Exceptions\PasswordInvalidException; use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\Auth\ResetPasswordRequest; -use HiEvents\Services\Handlers\Auth\DTO\ResetPasswordDTO; -use HiEvents\Services\Handlers\Auth\ResetPasswordHandler; +use HiEvents\Services\Application\Handlers\Auth\DTO\ResetPasswordDTO; +use HiEvents\Services\Application\Handlers\Auth\ResetPasswordHandler; use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; use Symfony\Component\Routing\Exception\ResourceNotFoundException; diff --git a/backend/app/Http/Actions/Auth/ValidateResetPasswordTokenAction.php b/backend/app/Http/Actions/Auth/ValidateResetPasswordTokenAction.php index 51e6a1c4cb..8516bb7991 100644 --- a/backend/app/Http/Actions/Auth/ValidateResetPasswordTokenAction.php +++ b/backend/app/Http/Actions/Auth/ValidateResetPasswordTokenAction.php @@ -4,7 +4,7 @@ use HiEvents\Exceptions\InvalidPasswordResetTokenException; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Services\Handlers\Auth\ValidateResetPasswordTokenHandler; +use HiEvents\Services\Application\Handlers\Auth\ValidateResetPasswordTokenHandler; use Illuminate\Http\Request; use Illuminate\Http\Response; use Symfony\Component\Routing\Exception\ResourceNotFoundException; diff --git a/backend/app/Http/Actions/BaseAction.php b/backend/app/Http/Actions/BaseAction.php index 90e7abecfb..9aba7e2d2e 100644 --- a/backend/app/Http/Actions/BaseAction.php +++ b/backend/app/Http/Actions/BaseAction.php @@ -66,6 +66,7 @@ protected function filterableResourceResponse( * @param int $statusCode * @param array $meta * @param array $headers + * @param array $errors * @return JsonResponse */ protected function resourceResponse( diff --git a/backend/app/Http/Actions/CapacityAssignments/CreateCapacityAssignmentAction.php b/backend/app/Http/Actions/CapacityAssignments/CreateCapacityAssignmentAction.php index 0eae8c0ded..d9cc6e1d97 100644 --- a/backend/app/Http/Actions/CapacityAssignments/CreateCapacityAssignmentAction.php +++ b/backend/app/Http/Actions/CapacityAssignments/CreateCapacityAssignmentAction.php @@ -6,9 +6,9 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\CapacityAssigment\UpsertCapacityAssignmentRequest; use HiEvents\Resources\CapacityAssignment\CapacityAssignmentResource; -use HiEvents\Services\Domain\Ticket\Exception\UnrecognizedTicketIdException; -use HiEvents\Services\Handlers\CapacityAssignment\CreateCapacityAssignmentHandler; -use HiEvents\Services\Handlers\CapacityAssignment\DTO\UpsertCapacityAssignmentDTO; +use HiEvents\Services\Application\Handlers\CapacityAssignment\CreateCapacityAssignmentHandler; +use HiEvents\Services\Application\Handlers\CapacityAssignment\DTO\UpsertCapacityAssignmentDTO; +use HiEvents\Services\Domain\Product\Exception\UnrecognizedProductIdException; use Illuminate\Http\JsonResponse; use Symfony\Component\HttpFoundation\Response; @@ -31,10 +31,10 @@ public function __invoke(int $eventId, UpsertCapacityAssignmentRequest $request) 'event_id' => $eventId, 'capacity' => $request->validated('capacity'), 'status' => $request->validated('status'), - 'ticket_ids' => $request->validated('ticket_ids'), + 'product_ids' => $request->validated('product_ids'), ]), ); - } catch (UnrecognizedTicketIdException $exception) { + } catch (UnrecognizedProductIdException $exception) { return $this->errorResponse( message: $exception->getMessage(), statusCode: Response::HTTP_UNPROCESSABLE_ENTITY, diff --git a/backend/app/Http/Actions/CapacityAssignments/DeleteCapacityAssignmentAction.php b/backend/app/Http/Actions/CapacityAssignments/DeleteCapacityAssignmentAction.php index 0a11c156a0..e31d6927bf 100644 --- a/backend/app/Http/Actions/CapacityAssignments/DeleteCapacityAssignmentAction.php +++ b/backend/app/Http/Actions/CapacityAssignments/DeleteCapacityAssignmentAction.php @@ -4,7 +4,7 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Services\Handlers\CapacityAssignment\DeleteCapacityAssignmentHandler; +use HiEvents\Services\Application\Handlers\CapacityAssignment\DeleteCapacityAssignmentHandler; use Illuminate\Http\Response; class DeleteCapacityAssignmentAction extends BaseAction diff --git a/backend/app/Http/Actions/CapacityAssignments/GetCapacityAssignmentAction.php b/backend/app/Http/Actions/CapacityAssignments/GetCapacityAssignmentAction.php index 15b61aaa98..676cb218d9 100644 --- a/backend/app/Http/Actions/CapacityAssignments/GetCapacityAssignmentAction.php +++ b/backend/app/Http/Actions/CapacityAssignments/GetCapacityAssignmentAction.php @@ -5,7 +5,7 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Http\Actions\BaseAction; use HiEvents\Resources\CapacityAssignment\CapacityAssignmentResource; -use HiEvents\Services\Handlers\CapacityAssignment\GetCapacityAssignmentHandler; +use HiEvents\Services\Application\Handlers\CapacityAssignment\GetCapacityAssignmentHandler; use Illuminate\Http\JsonResponse; class GetCapacityAssignmentAction extends BaseAction diff --git a/backend/app/Http/Actions/CapacityAssignments/GetCapacityAssignmentsAction.php b/backend/app/Http/Actions/CapacityAssignments/GetCapacityAssignmentsAction.php index 35ae5893e4..f6c4f791ba 100644 --- a/backend/app/Http/Actions/CapacityAssignments/GetCapacityAssignmentsAction.php +++ b/backend/app/Http/Actions/CapacityAssignments/GetCapacityAssignmentsAction.php @@ -6,8 +6,8 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Http\Actions\BaseAction; use HiEvents\Resources\CapacityAssignment\CapacityAssignmentResource; -use HiEvents\Services\Handlers\CapacityAssignment\DTO\GetCapacityAssignmentsDTO; -use HiEvents\Services\Handlers\CapacityAssignment\GetCapacityAssignmentsHandler; +use HiEvents\Services\Application\Handlers\CapacityAssignment\DTO\GetCapacityAssignmentsDTO; +use HiEvents\Services\Application\Handlers\CapacityAssignment\GetCapacityAssignmentsHandler; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; diff --git a/backend/app/Http/Actions/CapacityAssignments/UpdateCapacityAssignmentAction.php b/backend/app/Http/Actions/CapacityAssignments/UpdateCapacityAssignmentAction.php index 0deb55fb3b..b80035d7c1 100644 --- a/backend/app/Http/Actions/CapacityAssignments/UpdateCapacityAssignmentAction.php +++ b/backend/app/Http/Actions/CapacityAssignments/UpdateCapacityAssignmentAction.php @@ -6,9 +6,9 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\CapacityAssigment\UpsertCapacityAssignmentRequest; use HiEvents\Resources\CapacityAssignment\CapacityAssignmentResource; -use HiEvents\Services\Domain\Ticket\Exception\UnrecognizedTicketIdException; -use HiEvents\Services\Handlers\CapacityAssignment\DTO\UpsertCapacityAssignmentDTO; -use HiEvents\Services\Handlers\CapacityAssignment\UpdateCapacityAssignmentHandler; +use HiEvents\Services\Application\Handlers\CapacityAssignment\DTO\UpsertCapacityAssignmentDTO; +use HiEvents\Services\Application\Handlers\CapacityAssignment\UpdateCapacityAssignmentHandler; +use HiEvents\Services\Domain\Product\Exception\UnrecognizedProductIdException; use Illuminate\Http\JsonResponse; use Symfony\Component\HttpFoundation\Response; @@ -33,10 +33,10 @@ public function __invoke(int $eventId, int $capacityAssignmentId, UpsertCapacity 'capacity' => $request->validated('capacity'), 'applies_to' => $request->validated('applies_to'), 'status' => $request->validated('status'), - 'ticket_ids' => $request->validated('ticket_ids'), + 'product_ids' => $request->validated('product_ids'), ]), ); - } catch (UnrecognizedTicketIdException $exception) { + } catch (UnrecognizedProductIdException $exception) { return $this->errorResponse( message: $exception->getMessage(), statusCode: Response::HTTP_UNPROCESSABLE_ENTITY, diff --git a/backend/app/Http/Actions/CheckInLists/CreateCheckInListAction.php b/backend/app/Http/Actions/CheckInLists/CreateCheckInListAction.php index c10ada722e..19a962609e 100644 --- a/backend/app/Http/Actions/CheckInLists/CreateCheckInListAction.php +++ b/backend/app/Http/Actions/CheckInLists/CreateCheckInListAction.php @@ -5,9 +5,9 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\CheckInList\UpsertCheckInListRequest; use HiEvents\Resources\CheckInList\CheckInListResource; -use HiEvents\Services\Domain\Ticket\Exception\UnrecognizedTicketIdException; -use HiEvents\Services\Handlers\CheckInList\CreateCheckInListHandler; -use HiEvents\Services\Handlers\CheckInList\DTO\UpsertCheckInListDTO; +use HiEvents\Services\Application\Handlers\CheckInList\CreateCheckInListHandler; +use HiEvents\Services\Application\Handlers\CheckInList\DTO\UpsertCheckInListDTO; +use HiEvents\Services\Domain\Product\Exception\UnrecognizedProductIdException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; @@ -27,12 +27,12 @@ public function __invoke(UpsertCheckInListRequest $request, int $eventId): JsonR name: $request->validated('name'), description: $request->validated('description'), eventId: $eventId, - ticketIds: $request->validated('ticket_ids'), + productIds: $request->validated('product_ids'), expiresAt: $request->validated('expires_at'), activatesAt: $request->validated('activates_at'), ) ); - } catch (UnrecognizedTicketIdException $exception) { + } catch (UnrecognizedProductIdException $exception) { return $this->errorResponse( message: $exception->getMessage(), statusCode: Response::HTTP_UNPROCESSABLE_ENTITY, diff --git a/backend/app/Http/Actions/CheckInLists/DeleteCheckInListAction.php b/backend/app/Http/Actions/CheckInLists/DeleteCheckInListAction.php index 6341109e24..f707a0737a 100644 --- a/backend/app/Http/Actions/CheckInLists/DeleteCheckInListAction.php +++ b/backend/app/Http/Actions/CheckInLists/DeleteCheckInListAction.php @@ -4,7 +4,7 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Services\Handlers\CheckInList\DeleteCheckInListHandler; +use HiEvents\Services\Application\Handlers\CheckInList\DeleteCheckInListHandler; use Illuminate\Http\Response; class DeleteCheckInListAction extends BaseAction diff --git a/backend/app/Http/Actions/CheckInLists/GetCheckInListAction.php b/backend/app/Http/Actions/CheckInLists/GetCheckInListAction.php index eb7eb0e55f..6e60ce9df2 100644 --- a/backend/app/Http/Actions/CheckInLists/GetCheckInListAction.php +++ b/backend/app/Http/Actions/CheckInLists/GetCheckInListAction.php @@ -5,7 +5,7 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Http\Actions\BaseAction; use HiEvents\Resources\CheckInList\CheckInListResource; -use HiEvents\Services\Handlers\CheckInList\GetCheckInListHandler; +use HiEvents\Services\Application\Handlers\CheckInList\GetCheckInListHandler; use Illuminate\Http\JsonResponse; class GetCheckInListAction extends BaseAction diff --git a/backend/app/Http/Actions/CheckInLists/GetCheckInListsAction.php b/backend/app/Http/Actions/CheckInLists/GetCheckInListsAction.php index 137f22e5e0..bc7ca01d2f 100644 --- a/backend/app/Http/Actions/CheckInLists/GetCheckInListsAction.php +++ b/backend/app/Http/Actions/CheckInLists/GetCheckInListsAction.php @@ -6,8 +6,8 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Http\Actions\BaseAction; use HiEvents\Resources\CheckInList\CheckInListResource; -use HiEvents\Services\Handlers\CheckInList\DTO\GetCheckInListsDTO; -use HiEvents\Services\Handlers\CheckInList\GetCheckInListsHandler; +use HiEvents\Services\Application\Handlers\CheckInList\DTO\GetCheckInListsDTO; +use HiEvents\Services\Application\Handlers\CheckInList\GetCheckInListsHandler; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; diff --git a/backend/app/Http/Actions/CheckInLists/Public/CreateAttendeeCheckInPublicAction.php b/backend/app/Http/Actions/CheckInLists/Public/CreateAttendeeCheckInPublicAction.php index d3d3ae7778..ae5ea274b3 100644 --- a/backend/app/Http/Actions/CheckInLists/Public/CreateAttendeeCheckInPublicAction.php +++ b/backend/app/Http/Actions/CheckInLists/Public/CreateAttendeeCheckInPublicAction.php @@ -6,8 +6,8 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\CheckInList\CreateAttendeeCheckInPublicRequest; use HiEvents\Resources\CheckInList\AttendeeCheckInPublicResource; -use HiEvents\Services\Handlers\CheckInList\Public\CreateAttendeeCheckInPublicHandler; -use HiEvents\Services\Handlers\CheckInList\Public\DTO\CreateAttendeeCheckInPublicDTO; +use HiEvents\Services\Application\Handlers\CheckInList\Public\CreateAttendeeCheckInPublicHandler; +use HiEvents\Services\Application\Handlers\CheckInList\Public\DTO\CreateAttendeeCheckInPublicDTO; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; @@ -25,11 +25,11 @@ public function __invoke( ): JsonResponse { try { - $checkIns = $this->createAttendeeCheckInPublicHandler->handle(new CreateAttendeeCheckInPublicDTO( - checkInListUuid: $checkInListUuid, - checkInUserIpAddress: $request->ip(), - attendeePublicIds: $request->validated('attendee_public_ids'), - )); + $checkIns = $this->createAttendeeCheckInPublicHandler->handle(CreateAttendeeCheckInPublicDTO::from([ + 'checkInListUuid' => $checkInListUuid, + 'checkInUserIpAddress' => $request->ip(), + 'attendeesAndActions' => $request->validated('attendees'), + ])); } catch (CannotCheckInException $e) { return $this->errorResponse( message: $e->getMessage(), diff --git a/backend/app/Http/Actions/CheckInLists/Public/DeleteAttendeeCheckInPublicAction.php b/backend/app/Http/Actions/CheckInLists/Public/DeleteAttendeeCheckInPublicAction.php index 6062e04393..f19abe5244 100644 --- a/backend/app/Http/Actions/CheckInLists/Public/DeleteAttendeeCheckInPublicAction.php +++ b/backend/app/Http/Actions/CheckInLists/Public/DeleteAttendeeCheckInPublicAction.php @@ -4,8 +4,8 @@ use HiEvents\Exceptions\CannotCheckInException; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Services\Handlers\CheckInList\Public\DeleteAttendeeCheckInPublicHandler; -use HiEvents\Services\Handlers\CheckInList\Public\DTO\DeleteAttendeeCheckInPublicDTO; +use HiEvents\Services\Application\Handlers\CheckInList\Public\DeleteAttendeeCheckInPublicHandler; +use HiEvents\Services\Application\Handlers\CheckInList\Public\DTO\DeleteAttendeeCheckInPublicDTO; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; diff --git a/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeesPublicAction.php b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeesPublicAction.php index 5ca1818660..bffce8b0a1 100644 --- a/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeesPublicAction.php +++ b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeesPublicAction.php @@ -6,7 +6,7 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\DTO\QueryParamsDTO; use HiEvents\Resources\Attendee\AttendeeWithCheckInPublicResource; -use HiEvents\Services\Handlers\CheckInList\Public\GetCheckInListAttendeesPublicHandler; +use HiEvents\Services\Application\Handlers\CheckInList\Public\GetCheckInListAttendeesPublicHandler; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; diff --git a/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListPublicAction.php b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListPublicAction.php index 0b711f9d8b..fded28bf99 100644 --- a/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListPublicAction.php +++ b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListPublicAction.php @@ -4,7 +4,7 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Resources\CheckInList\CheckInListResourcePublic; -use HiEvents\Services\Handlers\CheckInList\Public\GetCheckInListPublicHandler; +use HiEvents\Services\Application\Handlers\CheckInList\Public\GetCheckInListPublicHandler; use Illuminate\Http\JsonResponse; class GetCheckInListPublicAction extends BaseAction diff --git a/backend/app/Http/Actions/CheckInLists/UpdateCheckInListAction.php b/backend/app/Http/Actions/CheckInLists/UpdateCheckInListAction.php index 5599617dae..dceda8c893 100644 --- a/backend/app/Http/Actions/CheckInLists/UpdateCheckInListAction.php +++ b/backend/app/Http/Actions/CheckInLists/UpdateCheckInListAction.php @@ -6,9 +6,9 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\CheckInList\UpsertCheckInListRequest; use HiEvents\Resources\CheckInList\CheckInListResource; -use HiEvents\Services\Domain\Ticket\Exception\UnrecognizedTicketIdException; -use HiEvents\Services\Handlers\CheckInList\DTO\UpsertCheckInListDTO; -use HiEvents\Services\Handlers\CheckInList\UpdateCheckInlistHandler; +use HiEvents\Services\Application\Handlers\CheckInList\DTO\UpsertCheckInListDTO; +use HiEvents\Services\Application\Handlers\CheckInList\UpdateCheckInlistHandler; +use HiEvents\Services\Domain\Product\Exception\UnrecognizedProductIdException; use Illuminate\Http\JsonResponse; use Symfony\Component\HttpFoundation\Response; @@ -30,13 +30,13 @@ public function __invoke(UpsertCheckInListRequest $request, int $eventId, int $c name: $request->validated('name'), description: $request->validated('description'), eventId: $eventId, - ticketIds: $request->validated('ticket_ids'), + productIds: $request->validated('product_ids'), expiresAt: $request->validated('expires_at'), activatesAt: $request->validated('activates_at'), id: $checkInListId, ) ); - } catch (UnrecognizedTicketIdException $exception) { + } catch (UnrecognizedProductIdException $exception) { return $this->errorResponse( message: $exception->getMessage(), statusCode: Response::HTTP_UNPROCESSABLE_ENTITY, diff --git a/backend/app/Http/Actions/Common/Webhooks/StripeIncomingWebhookAction.php b/backend/app/Http/Actions/Common/Webhooks/StripeIncomingWebhookAction.php index 075883d88e..bbd59a975c 100644 --- a/backend/app/Http/Actions/Common/Webhooks/StripeIncomingWebhookAction.php +++ b/backend/app/Http/Actions/Common/Webhooks/StripeIncomingWebhookAction.php @@ -4,28 +4,32 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\ResponseCodes; -use HiEvents\Services\Handlers\Order\Payment\Stripe\DTO\StripeWebhookDTO; -use HiEvents\Services\Handlers\Order\Payment\Stripe\IncomingWebhookHandler; +use HiEvents\Services\Application\Handlers\Order\Payment\Stripe\DTO\StripeWebhookDTO; +use HiEvents\Services\Application\Handlers\Order\Payment\Stripe\IncomingWebhookHandler; use Illuminate\Http\Request; use Illuminate\Http\Response; use Throwable; class StripeIncomingWebhookAction extends BaseAction { - private IncomingWebhookHandler $webhookHandler; - - public function __construct(IncomingWebhookHandler $webhookHandler) - { - $this->webhookHandler = $webhookHandler; - } - public function __invoke(Request $request): Response { try { - $this->webhookHandler->handle(new StripeWebhookDTO( - headerSignature: $request->server('HTTP_STRIPE_SIGNATURE'), - payload: $request->getContent(), - )); + $headerSignature = $request->server('HTTP_STRIPE_SIGNATURE'); + $payload = $request->getContent(); + + dispatch(static function (IncomingWebhookHandler $handler) use ($headerSignature, $payload) { + $handler->handle(new StripeWebhookDTO( + headerSignature: $headerSignature, + payload: $payload, + )); + })->catch(function (Throwable $exception) use ($payload) { + logger()->error(__('Failed to handle incoming Stripe webhook'), [ + 'exception' => $exception, + 'payload' => $payload, + ]); + }); + } catch (Throwable $exception) { logger()?->error($exception->getMessage(), $exception->getTrace()); return $this->noContentResponse(ResponseCodes::HTTP_BAD_REQUEST); diff --git a/backend/app/Http/Actions/EventSettings/EditEventSettingsAction.php b/backend/app/Http/Actions/EventSettings/EditEventSettingsAction.php index 610c100ef1..ff97d97837 100644 --- a/backend/app/Http/Actions/EventSettings/EditEventSettingsAction.php +++ b/backend/app/Http/Actions/EventSettings/EditEventSettingsAction.php @@ -6,8 +6,8 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\EventSettings\UpdateEventSettingsRequest; use HiEvents\Resources\Event\EventSettingsResource; -use HiEvents\Services\Handlers\EventSettings\DTO\UpdateEventSettingsDTO; -use HiEvents\Services\Handlers\EventSettings\UpdateEventSettingsHandler; +use HiEvents\Services\Application\Handlers\EventSettings\DTO\UpdateEventSettingsDTO; +use HiEvents\Services\Application\Handlers\EventSettings\UpdateEventSettingsHandler; use Illuminate\Http\JsonResponse; class EditEventSettingsAction extends BaseAction diff --git a/backend/app/Http/Actions/EventSettings/PartialEditEventSettingsAction.php b/backend/app/Http/Actions/EventSettings/PartialEditEventSettingsAction.php index 93dcb200af..09b20aabed 100644 --- a/backend/app/Http/Actions/EventSettings/PartialEditEventSettingsAction.php +++ b/backend/app/Http/Actions/EventSettings/PartialEditEventSettingsAction.php @@ -6,8 +6,8 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\EventSettings\UpdateEventSettingsRequest; use HiEvents\Resources\Event\EventSettingsResource; -use HiEvents\Services\Handlers\EventSettings\DTO\PartialUpdateEventSettingsDTO; -use HiEvents\Services\Handlers\EventSettings\PartialUpdateEventSettingsHandler; +use HiEvents\Services\Application\Handlers\EventSettings\DTO\PartialUpdateEventSettingsDTO; +use HiEvents\Services\Application\Handlers\EventSettings\PartialUpdateEventSettingsHandler; use Illuminate\Http\JsonResponse; use Throwable; diff --git a/backend/app/Http/Actions/Events/CreateEventAction.php b/backend/app/Http/Actions/Events/CreateEventAction.php index 997d253f27..088a90d09f 100644 --- a/backend/app/Http/Actions/Events/CreateEventAction.php +++ b/backend/app/Http/Actions/Events/CreateEventAction.php @@ -6,8 +6,8 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\Event\CreateEventRequest; use HiEvents\Resources\Event\EventResource; -use HiEvents\Services\Handlers\Event\CreateEventHandler; -use HiEvents\Services\Handlers\Event\DTO\CreateEventDTO; +use HiEvents\Services\Application\Handlers\Event\CreateEventHandler; +use HiEvents\Services\Application\Handlers\Event\DTO\CreateEventDTO; use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; use Throwable; diff --git a/backend/app/Http/Actions/Events/DuplicateEventAction.php b/backend/app/Http/Actions/Events/DuplicateEventAction.php index 2f6063da0c..da3514f593 100644 --- a/backend/app/Http/Actions/Events/DuplicateEventAction.php +++ b/backend/app/Http/Actions/Events/DuplicateEventAction.php @@ -6,8 +6,8 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\Event\DuplicateEventRequest; use HiEvents\Resources\Event\EventResource; +use HiEvents\Services\Application\Handlers\Event\DuplicateEventHandler; use HiEvents\Services\Domain\Event\DTO\DuplicateEventDataDTO; -use HiEvents\Services\Handlers\Event\DuplicateEventHandler; use Illuminate\Http\JsonResponse; use Throwable; @@ -29,13 +29,14 @@ public function __invoke(int $eventId, DuplicateEventRequest $request): JsonResp accountId: $this->getAuthenticatedAccountId(), title: $request->validated('title'), startDate: $request->validated('start_date'), - duplicateTickets: $request->validated('duplicate_tickets'), + duplicateProducts: $request->validated('duplicate_products'), duplicateQuestions: $request->validated('duplicate_questions'), duplicateSettings: $request->validated('duplicate_settings'), duplicatePromoCodes: $request->validated('duplicate_promo_codes'), duplicateCapacityAssignments: $request->validated('duplicate_capacity_assignments'), duplicateCheckInLists: $request->validated('duplicate_check_in_lists'), duplicateEventCoverImage: $request->validated('duplicate_event_cover_image'), + duplicateWebhooks: $request->validated('duplicate_webhooks'), 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 d2ac6f9c50..f1ca761169 100644 --- a/backend/app/Http/Actions/Events/GetEventAction.php +++ b/backend/app/Http/Actions/Events/GetEventAction.php @@ -6,9 +6,10 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; +use HiEvents\DomainObjects\ProductCategoryDomainObject; use HiEvents\DomainObjects\TaxAndFeesDomainObject; -use HiEvents\DomainObjects\TicketDomainObject; -use HiEvents\DomainObjects\TicketPriceDomainObject; +use HiEvents\DomainObjects\ProductDomainObject; +use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\Http\Actions\BaseAction; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\EventRepositoryInterface; @@ -31,10 +32,12 @@ public function __invoke(int $eventId): JsonResponse $event = $this->eventRepository ->loadRelation(new Relationship(domainObject: OrganizerDomainObject::class, name: 'organizer')) ->loadRelation( - new Relationship(TicketDomainObject::class, [ - new Relationship(TicketPriceDomainObject::class), - new Relationship(TaxAndFeesDomainObject::class), - ]), + new Relationship(ProductCategoryDomainObject::class, [ + new Relationship(ProductDomainObject::class, [ + new Relationship(ProductPriceDomainObject::class), + new Relationship(TaxAndFeesDomainObject::class), + ]), + ]) ) ->findById($eventId); diff --git a/backend/app/Http/Actions/Events/GetEventPublicAction.php b/backend/app/Http/Actions/Events/GetEventPublicAction.php index 144720264f..540153feb1 100644 --- a/backend/app/Http/Actions/Events/GetEventPublicAction.php +++ b/backend/app/Http/Actions/Events/GetEventPublicAction.php @@ -5,8 +5,8 @@ use HiEvents\DomainObjects\Status\EventStatus; use HiEvents\Http\Actions\BaseAction; use HiEvents\Resources\Event\EventResourcePublic; -use HiEvents\Services\Handlers\Event\DTO\GetPublicEventDTO; -use HiEvents\Services\Handlers\Event\GetPublicEventHandler; +use HiEvents\Services\Application\Handlers\Event\DTO\GetPublicEventDTO; +use HiEvents\Services\Application\Handlers\Event\GetPublicEventHandler; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -15,7 +15,7 @@ class GetEventPublicAction extends BaseAction { public function __construct( - private readonly GetPublicEventHandler $handler, + private readonly GetPublicEventHandler $getPublicEventHandler, private readonly LoggerInterface $logger, ) { @@ -23,7 +23,7 @@ public function __construct( public function __invoke(int $eventId, Request $request): Response|JsonResponse { - $event = $this->handler->handle(GetPublicEventDTO::fromArray([ + $event = $this->getPublicEventHandler->handle(GetPublicEventDTO::fromArray([ 'eventId' => $eventId, 'ipAddress' => $this->getClientIp($request), 'promoCode' => strtolower($request->string('promo_code')), diff --git a/backend/app/Http/Actions/Events/GetEventsAction.php b/backend/app/Http/Actions/Events/GetEventsAction.php index b9d17fec04..c269285b76 100644 --- a/backend/app/Http/Actions/Events/GetEventsAction.php +++ b/backend/app/Http/Actions/Events/GetEventsAction.php @@ -8,8 +8,8 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Http\Actions\BaseAction; use HiEvents\Resources\Event\EventResource; -use HiEvents\Services\Handlers\Event\DTO\GetEventsDTO; -use HiEvents\Services\Handlers\Event\GetEventsHandler; +use HiEvents\Services\Application\Handlers\Event\DTO\GetEventsDTO; +use HiEvents\Services\Application\Handlers\Event\GetEventsHandler; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; diff --git a/backend/app/Http/Actions/Events/GetOrganizerEventsPublicAction.php b/backend/app/Http/Actions/Events/GetOrganizerEventsPublicAction.php new file mode 100644 index 0000000000..a5404a159f --- /dev/null +++ b/backend/app/Http/Actions/Events/GetOrganizerEventsPublicAction.php @@ -0,0 +1,32 @@ +handler->handle(new GetPublicOrganizerEventsDTO( + organizerId: $organizerId, + queryParams: $this->getPaginationQueryParams($request), + )); + + return $this->resourceResponse( + resource: EventResourcePublic::class, + data: $events, + ); + } +} diff --git a/backend/app/Http/Actions/Events/Images/CreateEventImageAction.php b/backend/app/Http/Actions/Events/Images/CreateEventImageAction.php index 9ec812cbd3..02b6e8e236 100644 --- a/backend/app/Http/Actions/Events/Images/CreateEventImageAction.php +++ b/backend/app/Http/Actions/Events/Images/CreateEventImageAction.php @@ -6,8 +6,8 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\Event\CreateEventImageRequest; use HiEvents\Resources\Image\ImageResource; -use HiEvents\Services\Handlers\Event\CreateEventImageHandler; -use HiEvents\Services\Handlers\Event\DTO\CreateEventImageDTO; +use HiEvents\Services\Application\Handlers\Event\CreateEventImageHandler; +use HiEvents\Services\Application\Handlers\Event\DTO\CreateEventImageDTO; use Illuminate\Http\JsonResponse; class CreateEventImageAction extends BaseAction diff --git a/backend/app/Http/Actions/Events/Images/DeleteEventImageAction.php b/backend/app/Http/Actions/Events/Images/DeleteEventImageAction.php index b6363985cf..53ad71e9d8 100644 --- a/backend/app/Http/Actions/Events/Images/DeleteEventImageAction.php +++ b/backend/app/Http/Actions/Events/Images/DeleteEventImageAction.php @@ -4,8 +4,8 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Services\Handlers\Event\DeleteEventImageHandler; -use HiEvents\Services\Handlers\Event\DTO\DeleteEventImageDTO; +use HiEvents\Services\Application\Handlers\Event\DeleteEventImageHandler; +use HiEvents\Services\Application\Handlers\Event\DTO\DeleteEventImageDTO; use Illuminate\Http\Response; class DeleteEventImageAction extends BaseAction diff --git a/backend/app/Http/Actions/Events/Stats/GetEventCheckInStatsAction.php b/backend/app/Http/Actions/Events/Stats/GetEventCheckInStatsAction.php index 0f8bbb9185..24df05be46 100644 --- a/backend/app/Http/Actions/Events/Stats/GetEventCheckInStatsAction.php +++ b/backend/app/Http/Actions/Events/Stats/GetEventCheckInStatsAction.php @@ -4,7 +4,7 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Services\Handlers\Event\GetEventCheckInStatsHandler; +use HiEvents\Services\Application\Handlers\Event\GetEventCheckInStatsHandler; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; diff --git a/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php b/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php index a2d7a4dc4b..bd42face47 100644 --- a/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php +++ b/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php @@ -5,8 +5,8 @@ use Carbon\Carbon; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Services\Handlers\Event\DTO\EventStatsRequestDTO; -use HiEvents\Services\Handlers\Event\GetEventStatsHandler; +use HiEvents\Services\Application\Handlers\Event\DTO\EventStatsRequestDTO; +use HiEvents\Services\Application\Handlers\Event\GetEventStatsHandler; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; diff --git a/backend/app/Http/Actions/Events/UpdateEventAction.php b/backend/app/Http/Actions/Events/UpdateEventAction.php index 17dcba07bc..87b2c788cc 100644 --- a/backend/app/Http/Actions/Events/UpdateEventAction.php +++ b/backend/app/Http/Actions/Events/UpdateEventAction.php @@ -7,8 +7,8 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\Event\UpdateEventRequest; use HiEvents\Resources\Event\EventResource; -use HiEvents\Services\Handlers\Event\DTO\UpdateEventDTO; -use HiEvents\Services\Handlers\Event\UpdateEventHandler; +use HiEvents\Services\Application\Handlers\Event\DTO\UpdateEventDTO; +use HiEvents\Services\Application\Handlers\Event\UpdateEventHandler; use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; use Throwable; diff --git a/backend/app/Http/Actions/Events/UpdateEventStatusAction.php b/backend/app/Http/Actions/Events/UpdateEventStatusAction.php index a20ad150d8..0d33a45f54 100644 --- a/backend/app/Http/Actions/Events/UpdateEventStatusAction.php +++ b/backend/app/Http/Actions/Events/UpdateEventStatusAction.php @@ -8,8 +8,8 @@ use HiEvents\Http\Request\Event\UpdateEventStatusRequest; use HiEvents\Http\ResponseCodes; use HiEvents\Resources\Event\EventResource; -use HiEvents\Services\Handlers\Event\DTO\UpdateEventStatusDTO; -use HiEvents\Services\Handlers\Event\UpdateEventStatusHandler; +use HiEvents\Services\Application\Handlers\Event\DTO\UpdateEventStatusDTO; +use HiEvents\Services\Application\Handlers\Event\UpdateEventStatusHandler; use Illuminate\Http\JsonResponse; class UpdateEventStatusAction extends BaseAction diff --git a/backend/app/Http/Actions/Messages/SendMessageAction.php b/backend/app/Http/Actions/Messages/SendMessageAction.php index 5bdb1910ec..42c61ee866 100644 --- a/backend/app/Http/Actions/Messages/SendMessageAction.php +++ b/backend/app/Http/Actions/Messages/SendMessageAction.php @@ -7,8 +7,8 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\Message\SendMessageRequest; use HiEvents\Resources\Message\MessageResource; -use HiEvents\Services\Handlers\Message\DTO\SendMessageDTO; -use HiEvents\Services\Handlers\Message\SendMessageHandler; +use HiEvents\Services\Application\Handlers\Message\DTO\SendMessageDTO; +use HiEvents\Services\Application\Handlers\Message\SendMessageHandler; use Illuminate\Http\JsonResponse; use Symfony\Component\HttpFoundation\Response; @@ -36,7 +36,8 @@ public function __invoke(SendMessageRequest $request, int $eventId): JsonRespons 'is_test' => $request->input('is_test'), 'order_id' => $request->input('order_id'), 'attendee_ids' => $request->input('attendee_ids'), - 'ticket_ids' => $request->input('ticket_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'), 'sent_by_user_id' => $user->getId(), 'account_id' => $this->getAuthenticatedAccountId(), diff --git a/backend/app/Http/Actions/Orders/CancelOrderAction.php b/backend/app/Http/Actions/Orders/CancelOrderAction.php index ab98b514f1..d4853da991 100644 --- a/backend/app/Http/Actions/Orders/CancelOrderAction.php +++ b/backend/app/Http/Actions/Orders/CancelOrderAction.php @@ -7,8 +7,8 @@ use HiEvents\Exceptions\ResourceConflictException; use HiEvents\Http\Actions\BaseAction; use HiEvents\Resources\Order\OrderResource; -use HiEvents\Services\Handlers\Order\CancelOrderHandler; -use HiEvents\Services\Handlers\Order\DTO\CancelOrderDTO; +use HiEvents\Services\Application\Handlers\Order\CancelOrderHandler; +use HiEvents\Services\Application\Handlers\Order\DTO\CancelOrderDTO; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; use Symfony\Component\HttpFoundation\Response as HttpResponse; diff --git a/backend/app/Http/Actions/Orders/DownloadOrderInvoiceAction.php b/backend/app/Http/Actions/Orders/DownloadOrderInvoiceAction.php new file mode 100644 index 0000000000..b78a2dc466 --- /dev/null +++ b/backend/app/Http/Actions/Orders/DownloadOrderInvoiceAction.php @@ -0,0 +1,31 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $invoice = $this->orderInvoiceHandler->handle(new GetOrderInvoiceDTO( + orderId: $orderId, + eventId: $eventId, + )); + + return $invoice->pdf->stream($invoice->filename); + } +} diff --git a/backend/app/Http/Actions/Orders/EditOrderAction.php b/backend/app/Http/Actions/Orders/EditOrderAction.php new file mode 100644 index 0000000000..9153255c8f --- /dev/null +++ b/backend/app/Http/Actions/Orders/EditOrderAction.php @@ -0,0 +1,36 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $order = $this->handler->handle(new EditOrderDTO( + id: $orderId, + first_name: $request->validated('first_name'), + last_name: $request->validated('last_name'), + email: $request->validated('email'), + notes: $request->validated('notes'), + )); + + return $this->resourceResponse(OrderResource::class, $order); + } + +} diff --git a/backend/app/Http/Actions/Orders/ExportOrdersAction.php b/backend/app/Http/Actions/Orders/ExportOrdersAction.php index 3acbbd5fc9..66043857d6 100644 --- a/backend/app/Http/Actions/Orders/ExportOrdersAction.php +++ b/backend/app/Http/Actions/Orders/ExportOrdersAction.php @@ -28,6 +28,7 @@ public function __invoke(int $eventId): BinaryFileResponse $this->isActionAuthorized($eventId, EventDomainObject::class); $orders = $this->orderRepository + ->setMaxPerPage(10000) ->loadRelation(QuestionAndAnswerViewDomainObject::class) ->findByEventId($eventId, new QueryParamsDTO( page: 1, diff --git a/backend/app/Http/Actions/Orders/GetOrdersAction.php b/backend/app/Http/Actions/Orders/GetOrdersAction.php index 94824edc72..c8f9575dc9 100644 --- a/backend/app/Http/Actions/Orders/GetOrdersAction.php +++ b/backend/app/Http/Actions/Orders/GetOrdersAction.php @@ -4,10 +4,10 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\InvoiceDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Http\DTO\QueryParamsDTO; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Resources\Order\OrderResource; use Illuminate\Http\JsonResponse; @@ -29,7 +29,8 @@ public function __invoke(Request $request, int $eventId): JsonResponse $orders = $this->orderRepository ->loadRelation(OrderItemDomainObject::class) ->loadRelation(AttendeeDomainObject::class) - ->findByEventId($eventId, QueryParamsDTO::fromArray($request->query->all())); + ->loadRelation(InvoiceDomainObject::class) + ->findByEventId($eventId, $this->getPaginationQueryParams($request)); return $this->filterableResourceResponse( resource: OrderResource::class, diff --git a/backend/app/Http/Actions/Orders/MarkOrderAsPaidAction.php b/backend/app/Http/Actions/Orders/MarkOrderAsPaidAction.php new file mode 100644 index 0000000000..d9126afe3e --- /dev/null +++ b/backend/app/Http/Actions/Orders/MarkOrderAsPaidAction.php @@ -0,0 +1,37 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + try { + $order = $this->markOrderAsPaidHandler->handle(new MarkOrderAsPaidDTO($eventId, $orderId)); + } catch (ResourceConflictException $e) { + return $this->errorResponse($e->getMessage(), Response::HTTP_CONFLICT); + } + + return $this->resourceResponse( + resource: OrderResource::class, + data: $order, + ); + } +} diff --git a/backend/app/Http/Actions/Orders/Payment/RefundOrderAction.php b/backend/app/Http/Actions/Orders/Payment/RefundOrderAction.php index 5efd5d2cf5..205409e2ed 100644 --- a/backend/app/Http/Actions/Orders/Payment/RefundOrderAction.php +++ b/backend/app/Http/Actions/Orders/Payment/RefundOrderAction.php @@ -7,8 +7,8 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\Order\RefundOrderRequest; use HiEvents\Resources\Order\OrderResource; -use HiEvents\Services\Handlers\Order\DTO\RefundOrderDTO; -use HiEvents\Services\Handlers\Order\Payment\Stripe\RefundOrderHandler; +use HiEvents\Services\Application\Handlers\Order\DTO\RefundOrderDTO; +use HiEvents\Services\Application\Handlers\Order\Payment\Stripe\RefundOrderHandler; use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; use Stripe\Exception\ApiErrorException; diff --git a/backend/app/Http/Actions/Orders/Payment/Stripe/CreatePaymentIntentActionPublic.php b/backend/app/Http/Actions/Orders/Payment/Stripe/CreatePaymentIntentActionPublic.php index 06516697d3..1e3ee468ec 100644 --- a/backend/app/Http/Actions/Orders/Payment/Stripe/CreatePaymentIntentActionPublic.php +++ b/backend/app/Http/Actions/Orders/Payment/Stripe/CreatePaymentIntentActionPublic.php @@ -4,7 +4,7 @@ use HiEvents\Exceptions\Stripe\CreatePaymentIntentFailedException; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Services\Handlers\Order\Payment\Stripe\CreatePaymentIntentHandler; +use HiEvents\Services\Application\Handlers\Order\Payment\Stripe\CreatePaymentIntentHandler; use Illuminate\Http\JsonResponse; use Symfony\Component\HttpFoundation\Response; diff --git a/backend/app/Http/Actions/Orders/Payment/Stripe/GetPaymentIntentActionPublic.php b/backend/app/Http/Actions/Orders/Payment/Stripe/GetPaymentIntentActionPublic.php index 05dd6bf716..3b5073c1aa 100644 --- a/backend/app/Http/Actions/Orders/Payment/Stripe/GetPaymentIntentActionPublic.php +++ b/backend/app/Http/Actions/Orders/Payment/Stripe/GetPaymentIntentActionPublic.php @@ -3,7 +3,7 @@ namespace HiEvents\Http\Actions\Orders\Payment\Stripe; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Services\Handlers\Order\Payment\Stripe\GetPaymentIntentHandler; +use HiEvents\Services\Application\Handlers\Order\Payment\Stripe\GetPaymentIntentHandler; use Illuminate\Http\JsonResponse; class GetPaymentIntentActionPublic extends BaseAction diff --git a/backend/app/Http/Actions/Orders/CompleteOrderActionPublic.php b/backend/app/Http/Actions/Orders/Public/CompleteOrderActionPublic.php similarity index 81% rename from backend/app/Http/Actions/Orders/CompleteOrderActionPublic.php rename to backend/app/Http/Actions/Orders/Public/CompleteOrderActionPublic.php index 619c1dcbd1..68c46439c1 100644 --- a/backend/app/Http/Actions/Orders/CompleteOrderActionPublic.php +++ b/backend/app/Http/Actions/Orders/Public/CompleteOrderActionPublic.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace HiEvents\Http\Actions\Orders; +namespace HiEvents\Http\Actions\Orders\Public; use HiEvents\Exceptions\ResourceConflictException; use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\Order\CompleteOrderRequest; use HiEvents\Resources\Order\OrderResourcePublic; -use HiEvents\Services\Handlers\Order\CompleteOrderHandler; -use HiEvents\Services\Handlers\Order\DTO\CompleteOrderDTO; -use HiEvents\Services\Handlers\Order\DTO\CompleteOrderOrderDTO; +use HiEvents\Services\Application\Handlers\Order\CompleteOrderHandler; +use HiEvents\Services\Application\Handlers\Order\DTO\CompleteOrderDTO; +use HiEvents\Services\Application\Handlers\Order\DTO\CompleteOrderOrderDTO; use Illuminate\Http\JsonResponse; use Symfony\Component\HttpFoundation\Response; @@ -33,7 +33,7 @@ public function __invoke(CompleteOrderRequest $request, int $eventId, string $or ? $request->input('order.questions') : null, ]), - 'attendees' => $request->input('attendees'), + 'products' => $request->input('products'), ])); } catch (ResourceConflictException $e) { return $this->errorResponse($e->getMessage(), Response::HTTP_CONFLICT); diff --git a/backend/app/Http/Actions/Orders/CreateOrderActionPublic.php b/backend/app/Http/Actions/Orders/Public/CreateOrderActionPublic.php similarity index 80% rename from backend/app/Http/Actions/Orders/CreateOrderActionPublic.php rename to backend/app/Http/Actions/Orders/Public/CreateOrderActionPublic.php index c789ae6cf1..bf024a9df7 100644 --- a/backend/app/Http/Actions/Orders/CreateOrderActionPublic.php +++ b/backend/app/Http/Actions/Orders/Public/CreateOrderActionPublic.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace HiEvents\Http\Actions\Orders; +namespace HiEvents\Http\Actions\Orders\Public; use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\Order\CreateOrderRequest; use HiEvents\Http\ResponseCodes; use HiEvents\Resources\Order\OrderResourcePublic; +use HiEvents\Services\Application\Handlers\Order\CreateOrderHandler; +use HiEvents\Services\Application\Handlers\Order\DTO\CreateOrderPublicDTO; +use HiEvents\Services\Application\Handlers\Order\DTO\ProductOrderDetailsDTO; use HiEvents\Services\Application\Locale\LocaleService; use HiEvents\Services\Domain\Order\OrderCreateRequestValidationService; -use HiEvents\Services\Handlers\Order\CreateOrderHandler; -use HiEvents\Services\Handlers\Order\DTO\CreateOrderPublicDTO; -use HiEvents\Services\Handlers\Order\DTO\TicketOrderDetailsDTO; use HiEvents\Services\Infrastructure\Session\CheckoutSessionManagementService; use Illuminate\Http\JsonResponse; use Throwable; @@ -38,11 +38,11 @@ public function __invoke(CreateOrderRequest $request, int $eventId): JsonRespons $sessionId = $this->sessionIdentifierService->getSessionId(); $order = $this->orderHandler->handle( - $eventId, - CreateOrderPublicDTO::fromArray([ + eventId: $eventId, + createOrderPublicDTO: CreateOrderPublicDTO::fromArray([ 'is_user_authenticated' => $this->isUserAuthenticated(), 'promo_code' => $request->input('promo_code'), - 'tickets' => TicketOrderDetailsDTO::collectionFromArray($request->input('tickets')), + 'products' => ProductOrderDetailsDTO::collectionFromArray($request->input('products')), 'session_identifier' => $sessionId, 'order_locale' => $this->localeService->getLocaleOrDefault($request->getPreferredLanguage()), ]) diff --git a/backend/app/Http/Actions/Orders/Public/DownloadOrderInvoicePublicAction.php b/backend/app/Http/Actions/Orders/Public/DownloadOrderInvoicePublicAction.php new file mode 100644 index 0000000000..686662c63f --- /dev/null +++ b/backend/app/Http/Actions/Orders/Public/DownloadOrderInvoicePublicAction.php @@ -0,0 +1,26 @@ +downloadOrderInvoicePublicHandler->handle( + eventId: $eventId, + orderShortId: $orderShortId, + ); + + return $invoice->pdf->stream($invoice->filename); + } +} diff --git a/backend/app/Http/Actions/Orders/GetOrderActionPublic.php b/backend/app/Http/Actions/Orders/Public/GetOrderActionPublic.php similarity index 80% rename from backend/app/Http/Actions/Orders/GetOrderActionPublic.php rename to backend/app/Http/Actions/Orders/Public/GetOrderActionPublic.php index e516dae83e..01b7e0e925 100644 --- a/backend/app/Http/Actions/Orders/GetOrderActionPublic.php +++ b/backend/app/Http/Actions/Orders/Public/GetOrderActionPublic.php @@ -1,11 +1,11 @@ initializeOrderOfflinePaymentPublicHandler->handle( + TransitionOrderToOfflinePaymentPublicDTO::fromArray([ + 'orderShortId' => $orderShortId, + ]), + ); + + return $this->resourceResponse( + resource: OrderResourcePublic::class, + data: $order, + ); + } +} diff --git a/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php b/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php index 024aff0f9c..76c08cf9ec 100644 --- a/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php +++ b/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php @@ -5,6 +5,7 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract; +use HiEvents\DomainObjects\InvoiceDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Http\Actions\BaseAction; @@ -34,6 +35,7 @@ public function __invoke(int $eventId, int $orderId): Response $order = $this->orderRepository ->loadRelation(OrderItemDomainObject::class) + ->loadRelation(InvoiceDomainObject::class) ->findFirstWhere([ OrderDomainObjectAbstract::EVENT_ID => $eventId, OrderDomainObjectAbstract::ID => $orderId, @@ -57,6 +59,7 @@ public function __invoke(int $eventId, int $orderId): Response event: $event, organizer: $event->getOrganizer(), eventSettings: $event->getEventSettings(), + invoice: $order->getLatestInvoice(), )); } diff --git a/backend/app/Http/Actions/Organizers/CreateOrganizerAction.php b/backend/app/Http/Actions/Organizers/CreateOrganizerAction.php index ba71304b1c..85448d0bf8 100644 --- a/backend/app/Http/Actions/Organizers/CreateOrganizerAction.php +++ b/backend/app/Http/Actions/Organizers/CreateOrganizerAction.php @@ -6,8 +6,8 @@ use HiEvents\Http\Request\Organizer\UpsertOrganizerRequest; use HiEvents\Http\ResponseCodes; use HiEvents\Resources\Organizer\OrganizerResource; -use HiEvents\Services\Handlers\Organizer\CreateOrganizerHandler; -use HiEvents\Services\Handlers\Organizer\DTO\CreateOrganizerDTO; +use HiEvents\Services\Application\Handlers\Organizer\CreateOrganizerHandler; +use HiEvents\Services\Application\Handlers\Organizer\DTO\CreateOrganizerDTO; use Illuminate\Http\JsonResponse; class CreateOrganizerAction extends BaseAction diff --git a/backend/app/Http/Actions/Organizers/EditOrganizerAction.php b/backend/app/Http/Actions/Organizers/EditOrganizerAction.php index 8e08c9e026..4aae07dc6d 100644 --- a/backend/app/Http/Actions/Organizers/EditOrganizerAction.php +++ b/backend/app/Http/Actions/Organizers/EditOrganizerAction.php @@ -6,8 +6,8 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\Organizer\UpsertOrganizerRequest; use HiEvents\Resources\Organizer\OrganizerResource; -use HiEvents\Services\Handlers\Organizer\DTO\EditOrganizerDTO; -use HiEvents\Services\Handlers\Organizer\EditOrganizerHandler; +use HiEvents\Services\Application\Handlers\Organizer\DTO\EditOrganizerDTO; +use HiEvents\Services\Application\Handlers\Organizer\EditOrganizerHandler; use Illuminate\Http\JsonResponse; class EditOrganizerAction extends BaseAction diff --git a/backend/app/Http/Actions/Organizers/GetOrganizerEventsAction.php b/backend/app/Http/Actions/Organizers/GetOrganizerEventsAction.php index 0f90d3c97b..b5e8aed696 100644 --- a/backend/app/Http/Actions/Organizers/GetOrganizerEventsAction.php +++ b/backend/app/Http/Actions/Organizers/GetOrganizerEventsAction.php @@ -7,8 +7,8 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\DTO\QueryParamsDTO; use HiEvents\Resources\Event\EventResource; -use HiEvents\Services\Handlers\Organizer\DTO\GetOrganizerEventsDTO; -use HiEvents\Services\Handlers\Organizer\GetOrganizerEventsHandler; +use HiEvents\Services\Application\Handlers\Organizer\DTO\GetOrganizerEventsDTO; +use HiEvents\Services\Application\Handlers\Organizer\GetOrganizerEventsHandler; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; diff --git a/backend/app/Http/Actions/Organizers/GetPublicOrganizerAction.php b/backend/app/Http/Actions/Organizers/GetPublicOrganizerAction.php new file mode 100644 index 0000000000..78604b9c75 --- /dev/null +++ b/backend/app/Http/Actions/Organizers/GetPublicOrganizerAction.php @@ -0,0 +1,25 @@ +resourceResponse( + resource: OrganizerResourcePublic::class, + data: $this->handler->handle($organizerId), + ); + } +} diff --git a/backend/app/Http/Actions/ProductCategories/CreateProductCategoryAction.php b/backend/app/Http/Actions/ProductCategories/CreateProductCategoryAction.php new file mode 100644 index 0000000000..d3b8460043 --- /dev/null +++ b/backend/app/Http/Actions/ProductCategories/CreateProductCategoryAction.php @@ -0,0 +1,40 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $productCategory = $this->handler->handle(new UpsertProductCategoryDTO( + name: $request->validated('name'), + description: $request->validated('description'), + is_hidden: $request->validated('is_hidden'), + event_id: $eventId, + no_products_message: $request->validated('no_products_message'), + )); + + return $this->resourceResponse( + resource: ProductCategoryResource::class, + data: $productCategory, + statusCode: ResponseCodes::HTTP_CREATED, + ); + } +} diff --git a/backend/app/Http/Actions/ProductCategories/DeleteProductCategoryAction.php b/backend/app/Http/Actions/ProductCategories/DeleteProductCategoryAction.php new file mode 100644 index 0000000000..aba5e2f066 --- /dev/null +++ b/backend/app/Http/Actions/ProductCategories/DeleteProductCategoryAction.php @@ -0,0 +1,46 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + try { + $this->deleteProductCategoryHandler->handle( + productCategoryId: $productCategoryId, + eventId: $eventId, + ); + } catch (CannotDeleteEntityException $exception) { + return $this->errorResponse( + message: $exception->getMessage(), + statusCode: Response::HTTP_CONFLICT, + ); + } + + return $this->deletedResponse(); + } +} diff --git a/backend/app/Http/Actions/ProductCategories/EditProductCategoryAction.php b/backend/app/Http/Actions/ProductCategories/EditProductCategoryAction.php new file mode 100644 index 0000000000..56a64fb20a --- /dev/null +++ b/backend/app/Http/Actions/ProductCategories/EditProductCategoryAction.php @@ -0,0 +1,42 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $request->merge([ + 'event_id' => $eventId, + 'account_id' => $this->getAuthenticatedAccountId(), + 'product_category_id' => $productCategoryId, + ]); + + $productCategory = $this->editProductCategoryHandler->handle(new UpsertProductCategoryDTO( + name: $request->validated('name'), + description: $request->validated('description'), + is_hidden: $request->validated('is_hidden'), + event_id: $eventId, + no_products_message: $request->validated('no_products_message'), + product_category_id: $productCategoryId, + )); + + return $this->resourceResponse(ProductCategoryResource::class, $productCategory); + } +} diff --git a/backend/app/Http/Actions/ProductCategories/GetProductCategoriesAction.php b/backend/app/Http/Actions/ProductCategories/GetProductCategoriesAction.php new file mode 100644 index 0000000000..daf9512e8b --- /dev/null +++ b/backend/app/Http/Actions/ProductCategories/GetProductCategoriesAction.php @@ -0,0 +1,30 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $categories = $this->getProductCategoriesHandler->handle($eventId); + + return $this->resourceResponse( + resource: ProductCategoryResource::class, + data: $categories, + ); + } +} diff --git a/backend/app/Http/Actions/ProductCategories/GetProductCategoryAction.php b/backend/app/Http/Actions/ProductCategories/GetProductCategoryAction.php new file mode 100644 index 0000000000..ddec4dd581 --- /dev/null +++ b/backend/app/Http/Actions/ProductCategories/GetProductCategoryAction.php @@ -0,0 +1,30 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $category = $this->getProductCategoryHandler->handle($eventId, $productCategoryId); + + return $this->resourceResponse( + resource: ProductCategoryResource::class, + data: $category, + ); + } +} diff --git a/backend/app/Http/Actions/Tickets/CreateTicketAction.php b/backend/app/Http/Actions/Products/CreateProductAction.php similarity index 53% rename from backend/app/Http/Actions/Tickets/CreateTicketAction.php rename to backend/app/Http/Actions/Products/CreateProductAction.php index 7340b99b6a..905474b9d0 100644 --- a/backend/app/Http/Actions/Tickets/CreateTicketAction.php +++ b/backend/app/Http/Actions/Products/CreateProductAction.php @@ -2,33 +2,33 @@ declare(strict_types=1); -namespace HiEvents\Http\Actions\Tickets; +namespace HiEvents\Http\Actions\Products; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Exceptions\InvalidTaxOrFeeIdException; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Http\Request\Ticket\UpsertTicketRequest; +use HiEvents\Http\Request\Product\UpsertProductRequest; use HiEvents\Http\ResponseCodes; -use HiEvents\Resources\Ticket\TicketResource; -use HiEvents\Services\Handlers\Ticket\CreateTicketHandler; -use HiEvents\Services\Handlers\Ticket\DTO\UpsertTicketDTO; +use HiEvents\Resources\Product\ProductResource; +use HiEvents\Services\Application\Handlers\Product\CreateProductHandler; +use HiEvents\Services\Application\Handlers\Product\DTO\UpsertProductDTO; use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; use Throwable; -class CreateTicketAction extends BaseAction +class CreateProductAction extends BaseAction { - private CreateTicketHandler $createTicketHandler; + private CreateProductHandler $createProductHandler; - public function __construct(CreateTicketHandler $handler) + public function __construct(CreateProductHandler $handler) { - $this->createTicketHandler = $handler; + $this->createProductHandler = $handler; } /** * @throws Throwable */ - public function __invoke(int $eventId, UpsertTicketRequest $request): JsonResponse + public function __invoke(int $eventId, UpsertProductRequest $request): JsonResponse { $this->isActionAuthorized($eventId, EventDomainObject::class); @@ -38,7 +38,7 @@ public function __invoke(int $eventId, UpsertTicketRequest $request): JsonRespon ]); try { - $ticket = $this->createTicketHandler->handle(UpsertTicketDTO::fromArray($request->all())); + $product = $this->createProductHandler->handle(UpsertProductDTO::fromArray($request->all())); } catch (InvalidTaxOrFeeIdException $e) { throw ValidationException::withMessages([ 'tax_and_fee_ids' => $e->getMessage(), @@ -46,8 +46,8 @@ public function __invoke(int $eventId, UpsertTicketRequest $request): JsonRespon } return $this->resourceResponse( - resource: TicketResource::class, - data: $ticket, + resource: ProductResource::class, + data: $product, statusCode: ResponseCodes::HTTP_CREATED, ); } diff --git a/backend/app/Http/Actions/Tickets/DeleteTicketAction.php b/backend/app/Http/Actions/Products/DeleteProductAction.php similarity index 59% rename from backend/app/Http/Actions/Tickets/DeleteTicketAction.php rename to backend/app/Http/Actions/Products/DeleteProductAction.php index 7c51cad3f1..9a34a6c7a7 100644 --- a/backend/app/Http/Actions/Tickets/DeleteTicketAction.php +++ b/backend/app/Http/Actions/Products/DeleteProductAction.php @@ -2,32 +2,32 @@ declare(strict_types=1); -namespace HiEvents\Http\Actions\Tickets; +namespace HiEvents\Http\Actions\Products; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Exceptions\CannotDeleteEntityException; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Services\Handlers\Ticket\DeleteTicketHandler; +use HiEvents\Services\Application\Handlers\Product\DeleteProductHandler; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; use Symfony\Component\HttpFoundation\Response as HttpResponse; -class DeleteTicketAction extends BaseAction +class DeleteProductAction extends BaseAction { - private DeleteTicketHandler $deleteTicketHandler; + private DeleteProductHandler $deleteProductHandler; - public function __construct(DeleteTicketHandler $handler) + public function __construct(DeleteProductHandler $handler) { - $this->deleteTicketHandler = $handler; + $this->deleteProductHandler = $handler; } - public function __invoke(int $eventId, int $ticketId): Response|JsonResponse + public function __invoke(int $eventId, int $productId): Response|JsonResponse { $this->isActionAuthorized($eventId, EventDomainObject::class); try { - $this->deleteTicketHandler->handle( - ticketId: $ticketId, + $this->deleteProductHandler->handle( + productId: $productId, eventId: $eventId, ); } catch (CannotDeleteEntityException $exception) { diff --git a/backend/app/Http/Actions/Tickets/EditTicketAction.php b/backend/app/Http/Actions/Products/EditProductAction.php similarity index 52% rename from backend/app/Http/Actions/Tickets/EditTicketAction.php rename to backend/app/Http/Actions/Products/EditProductAction.php index d03111e83a..c9f79d1ca9 100644 --- a/backend/app/Http/Actions/Tickets/EditTicketAction.php +++ b/backend/app/Http/Actions/Products/EditProductAction.php @@ -2,24 +2,24 @@ declare(strict_types=1); -namespace HiEvents\Http\Actions\Tickets; +namespace HiEvents\Http\Actions\Products; use HiEvents\DomainObjects\EventDomainObject; -use HiEvents\Exceptions\CannotChangeTicketTypeException; +use HiEvents\Exceptions\CannotChangeProductTypeException; use HiEvents\Exceptions\InvalidTaxOrFeeIdException; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Http\Request\Ticket\UpsertTicketRequest; -use HiEvents\Resources\Ticket\TicketResource; -use HiEvents\Services\Handlers\Ticket\DTO\UpsertTicketDTO; -use HiEvents\Services\Handlers\Ticket\EditTicketHandler; +use HiEvents\Http\Request\Product\UpsertProductRequest; +use HiEvents\Resources\Product\ProductResource; +use HiEvents\Services\Application\Handlers\Product\DTO\UpsertProductDTO; +use HiEvents\Services\Application\Handlers\Product\EditProductHandler; use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; use Throwable; -class EditTicketAction extends BaseAction +class EditProductAction extends BaseAction { public function __construct( - private readonly EditTicketHandler $editTicketHandler, + private readonly EditProductHandler $editProductHandler, ) { } @@ -28,28 +28,28 @@ public function __construct( * @throws Throwable * @throws ValidationException */ - public function __invoke(UpsertTicketRequest $request, int $eventId, int $ticketId): JsonResponse + public function __invoke(UpsertProductRequest $request, int $eventId, int $productId): JsonResponse { $this->isActionAuthorized($eventId, EventDomainObject::class); $request->merge([ 'event_id' => $eventId, 'account_id' => $this->getAuthenticatedAccountId(), - 'ticket_id' => $ticketId, + 'product_id' => $productId, ]); try { - $ticket = $this->editTicketHandler->handle(UpsertTicketDTO::fromArray($request->all())); + $product = $this->editProductHandler->handle(UpsertProductDTO::fromArray($request->all())); } catch (InvalidTaxOrFeeIdException $e) { throw ValidationException::withMessages([ 'tax_and_fee_ids' => $e->getMessage(), ]); - } catch (CannotChangeTicketTypeException $e) { + } catch (CannotChangeProductTypeException $e) { throw ValidationException::withMessages([ 'type' => $e->getMessage(), ]); } - return $this->resourceResponse(TicketResource::class, $ticket); + return $this->resourceResponse(ProductResource::class, $product); } } diff --git a/backend/app/Http/Actions/Products/GetProductAction.php b/backend/app/Http/Actions/Products/GetProductAction.php new file mode 100644 index 0000000000..e7066d6541 --- /dev/null +++ b/backend/app/Http/Actions/Products/GetProductAction.php @@ -0,0 +1,44 @@ +productRepository = $productRepository; + } + + public function __invoke(int $eventId, int $productId): JsonResponse|Response + { + $this->isActionAuthorized($eventId, EventDomainObject::class); + + $product = $this->productRepository + ->loadRelation(TaxAndFeesDomainObject::class) + ->loadRelation(ProductPriceDomainObject::class) + ->findFirstWhere([ + ProductDomainObjectAbstract::EVENT_ID => $eventId, + ProductDomainObjectAbstract::ID => $productId, + ]); + + if ($product === null) { + return $this->notFoundResponse(); + } + + return $this->resourceResponse(ProductResource::class, $product); + } +} diff --git a/backend/app/Http/Actions/Tickets/GetTicketsAction.php b/backend/app/Http/Actions/Products/GetProductsAction.php similarity index 53% rename from backend/app/Http/Actions/Tickets/GetTicketsAction.php rename to backend/app/Http/Actions/Products/GetProductsAction.php index e962de2692..d71e5e1c2d 100644 --- a/backend/app/Http/Actions/Tickets/GetTicketsAction.php +++ b/backend/app/Http/Actions/Products/GetProductsAction.php @@ -2,20 +2,20 @@ declare(strict_types=1); -namespace HiEvents\Http\Actions\Tickets; +namespace HiEvents\Http\Actions\Products; use HiEvents\DomainObjects\EventDomainObject; -use HiEvents\DomainObjects\TicketDomainObject; +use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Resources\Ticket\TicketResource; -use HiEvents\Services\Handlers\Ticket\GetTicketsHandler; +use HiEvents\Resources\Product\ProductResource; +use HiEvents\Services\Application\Handlers\Product\GetProductsHandler; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -class GetTicketsAction extends BaseAction +class GetProductsAction extends BaseAction { public function __construct( - private readonly GetTicketsHandler $getTicketsHandler, + private readonly GetProductsHandler $getProductsHandler, ) { } @@ -24,15 +24,15 @@ public function __invoke(int $eventId, Request $request): JsonResponse { $this->isActionAuthorized($eventId, EventDomainObject::class); - $tickets = $this->getTicketsHandler->handle( + $products = $this->getProductsHandler->handle( eventId: $eventId, queryParamsDTO: $this->getPaginationQueryParams($request), ); return $this->filterableResourceResponse( - resource: TicketResource::class, - data: $tickets, - domainObject: TicketDomainObject::class + resource: ProductResource::class, + data: $products, + domainObject: ProductDomainObject::class ); } } diff --git a/backend/app/Http/Actions/Tickets/SortTicketsAction.php b/backend/app/Http/Actions/Products/SortProductsAction.php similarity index 55% rename from backend/app/Http/Actions/Tickets/SortTicketsAction.php rename to backend/app/Http/Actions/Products/SortProductsAction.php index 1e17775722..ea5f4504d5 100644 --- a/backend/app/Http/Actions/Tickets/SortTicketsAction.php +++ b/backend/app/Http/Actions/Products/SortProductsAction.php @@ -1,31 +1,31 @@ isActionAuthorized($eventId, EventDomainObject::class); try { - $this->sortTicketsHandler->handle( + $this->sortProductsHandler->handle( $eventId, - $request->validated(), + $request->validated('sorted_categories'), ); } catch (ResourceConflictException $e) { return $this->errorResponse($e->getMessage(), Response::HTTP_CONFLICT); diff --git a/backend/app/Http/Actions/PromoCodes/CreatePromoCodeAction.php b/backend/app/Http/Actions/PromoCodes/CreatePromoCodeAction.php index f1c8b8b74e..0e2bc37f73 100644 --- a/backend/app/Http/Actions/PromoCodes/CreatePromoCodeAction.php +++ b/backend/app/Http/Actions/PromoCodes/CreatePromoCodeAction.php @@ -9,9 +9,9 @@ use HiEvents\Http\Request\PromoCode\CreateUpdatePromoCodeRequest; use HiEvents\Http\ResponseCodes; use HiEvents\Resources\PromoCode\PromoCodeResource; -use HiEvents\Services\Domain\Ticket\Exception\UnrecognizedTicketIdException; -use HiEvents\Services\Handlers\PromoCode\CreatePromoCodeHandler; -use HiEvents\Services\Handlers\PromoCode\DTO\UpsertPromoCodeDTO; +use HiEvents\Services\Application\Handlers\PromoCode\CreatePromoCodeHandler; +use HiEvents\Services\Application\Handlers\PromoCode\DTO\UpsertPromoCodeDTO; +use HiEvents\Services\Domain\Product\Exception\UnrecognizedProductIdException; use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; @@ -35,7 +35,7 @@ public function __invoke(CreateUpdatePromoCodeRequest $request, int $eventId): J $promoCode = $this->createPromoCodeHandler->handle($eventId, new UpsertPromoCodeDTO( code: strtolower($request->input('code')), event_id: $eventId, - applicable_ticket_ids: $request->input('applicable_ticket_ids'), + applicable_product_ids: $request->input('applicable_product_ids'), discount_type: PromoCodeDiscountTypeEnum::fromName($request->input('discount_type')), discount: $request->float('discount'), expiry_date: $request->input('expiry_date'), @@ -45,9 +45,9 @@ public function __invoke(CreateUpdatePromoCodeRequest $request, int $eventId): J throw ValidationException::withMessages([ 'code' => $e->getMessage(), ]); - } catch (UnrecognizedTicketIdException $e) { + } catch (UnrecognizedProductIdException $e) { throw ValidationException::withMessages([ - 'applicable_ticket_ids' => $e->getMessage(), + 'applicable_product_ids' => $e->getMessage(), ]); } diff --git a/backend/app/Http/Actions/PromoCodes/DeletePromoCodeAction.php b/backend/app/Http/Actions/PromoCodes/DeletePromoCodeAction.php index ae61f4e647..f6bc2be6e0 100644 --- a/backend/app/Http/Actions/PromoCodes/DeletePromoCodeAction.php +++ b/backend/app/Http/Actions/PromoCodes/DeletePromoCodeAction.php @@ -4,8 +4,8 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Services\Handlers\PromoCode\DeletePromoCodeHandler; -use HiEvents\Services\Handlers\PromoCode\DTO\DeletePromoCodeDTO; +use HiEvents\Services\Application\Handlers\PromoCode\DeletePromoCodeHandler; +use HiEvents\Services\Application\Handlers\PromoCode\DTO\DeletePromoCodeDTO; use Illuminate\Http\Request; use Illuminate\Http\Response; diff --git a/backend/app/Http/Actions/PromoCodes/UpdatePromoCodeAction.php b/backend/app/Http/Actions/PromoCodes/UpdatePromoCodeAction.php index 0416ed8073..d9ea210938 100644 --- a/backend/app/Http/Actions/PromoCodes/UpdatePromoCodeAction.php +++ b/backend/app/Http/Actions/PromoCodes/UpdatePromoCodeAction.php @@ -9,9 +9,9 @@ use HiEvents\Http\Request\PromoCode\CreateUpdatePromoCodeRequest; use HiEvents\Http\ResponseCodes; use HiEvents\Resources\PromoCode\PromoCodeResource; -use HiEvents\Services\Domain\Ticket\Exception\UnrecognizedTicketIdException; -use HiEvents\Services\Handlers\PromoCode\DTO\UpsertPromoCodeDTO; -use HiEvents\Services\Handlers\PromoCode\UpdatePromoCodeHandler; +use HiEvents\Services\Application\Handlers\PromoCode\DTO\UpsertPromoCodeDTO; +use HiEvents\Services\Application\Handlers\PromoCode\UpdatePromoCodeHandler; +use HiEvents\Services\Domain\Product\Exception\UnrecognizedProductIdException; use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; @@ -35,7 +35,7 @@ public function __invoke(CreateUpdatePromoCodeRequest $request, int $eventId, in $promoCode = $this->updatePromoCodeHandler->handle($promoCodeId, new UpsertPromoCodeDTO( code: strtolower($request->input('code')), event_id: $eventId, - applicable_ticket_ids: $request->input('applicable_ticket_ids'), + applicable_product_ids: $request->input('applicable_product_ids'), discount_type: PromoCodeDiscountTypeEnum::fromName($request->input('discount_type')), discount: $request->float('discount'), expiry_date: $request->input('expiry_date'), @@ -45,9 +45,9 @@ public function __invoke(CreateUpdatePromoCodeRequest $request, int $eventId, in throw ValidationException::withMessages([ 'code' => $e->getMessage(), ]); - } catch (UnrecognizedTicketIdException $e) { + } catch (UnrecognizedProductIdException $e) { throw ValidationException::withMessages([ - 'applicable_ticket_ids' => $e->getMessage(), + 'applicable_product_ids' => $e->getMessage(), ]); } diff --git a/backend/app/Http/Actions/Questions/CreateQuestionAction.php b/backend/app/Http/Actions/Questions/CreateQuestionAction.php index a5a9ca71dd..7c65efc1b0 100644 --- a/backend/app/Http/Actions/Questions/CreateQuestionAction.php +++ b/backend/app/Http/Actions/Questions/CreateQuestionAction.php @@ -7,8 +7,8 @@ use HiEvents\Http\Request\Questions\UpsertQuestionRequest; use HiEvents\Http\ResponseCodes; use HiEvents\Resources\Question\QuestionResource; -use HiEvents\Services\Handlers\Question\CreateQuestionHandler; -use HiEvents\Services\Handlers\Question\DTO\UpsertQuestionDTO; +use HiEvents\Services\Application\Handlers\Question\CreateQuestionHandler; +use HiEvents\Services\Application\Handlers\Question\DTO\UpsertQuestionDTO; use Illuminate\Http\JsonResponse; class CreateQuestionAction extends BaseAction @@ -30,7 +30,7 @@ public function __invoke(UpsertQuestionRequest $request, int $eventId): JsonResp 'required' => $request->boolean('required'), 'options' => $request->input('options'), 'event_id' => $eventId, - 'ticket_ids' => $request->input('ticket_ids'), + 'product_ids' => $request->input('product_ids'), 'belongs_to' => $request->input('belongs_to'), 'is_hidden' => $request->boolean('is_hidden'), 'description' => $request->input('description'), diff --git a/backend/app/Http/Actions/Questions/DeleteQuestionAction.php b/backend/app/Http/Actions/Questions/DeleteQuestionAction.php index e30bbc17fb..1b7869266b 100644 --- a/backend/app/Http/Actions/Questions/DeleteQuestionAction.php +++ b/backend/app/Http/Actions/Questions/DeleteQuestionAction.php @@ -5,7 +5,7 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Exceptions\CannotDeleteEntityException; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Services\Handlers\Question\DeleteQuestionHandler; +use HiEvents\Services\Application\Handlers\Question\DeleteQuestionHandler; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; use Throwable; diff --git a/backend/app/Http/Actions/Questions/EditQuestionAction.php b/backend/app/Http/Actions/Questions/EditQuestionAction.php index 616584e972..847f4d0190 100644 --- a/backend/app/Http/Actions/Questions/EditQuestionAction.php +++ b/backend/app/Http/Actions/Questions/EditQuestionAction.php @@ -8,8 +8,8 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\Questions\UpsertQuestionRequest; use HiEvents\Resources\Question\QuestionResource; -use HiEvents\Services\Handlers\Question\DTO\UpsertQuestionDTO; -use HiEvents\Services\Handlers\Question\EditQuestionHandler; +use HiEvents\Services\Application\Handlers\Question\DTO\UpsertQuestionDTO; +use HiEvents\Services\Application\Handlers\Question\EditQuestionHandler; use Illuminate\Http\JsonResponse; use Throwable; @@ -37,7 +37,7 @@ public function __invoke(UpsertQuestionRequest $request, int $eventId, int $ques 'required' => $request->boolean('required'), 'options' => $request->input('options'), 'event_id' => $eventId, - 'ticket_ids' => $request->input('ticket_ids'), + 'product_ids' => $request->input('product_ids'), 'is_hidden' => $request->boolean('is_hidden'), 'belongs_to' => QuestionBelongsTo::fromName($request->input('belongs_to')), 'description' => $request->input('description'), diff --git a/backend/app/Http/Actions/Questions/GetQuestionAction.php b/backend/app/Http/Actions/Questions/GetQuestionAction.php index 6759fd3d4c..6701e7ce3f 100644 --- a/backend/app/Http/Actions/Questions/GetQuestionAction.php +++ b/backend/app/Http/Actions/Questions/GetQuestionAction.php @@ -3,7 +3,7 @@ namespace HiEvents\Http\Actions\Questions; use HiEvents\DomainObjects\EventDomainObject; -use HiEvents\DomainObjects\TicketDomainObject; +use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\Http\Actions\BaseAction; use HiEvents\Repository\Interfaces\QuestionRepositoryInterface; use HiEvents\Resources\Question\QuestionResource; @@ -24,7 +24,7 @@ public function __invoke(Request $request, int $eventId, int $questionId): JsonR $this->isActionAuthorized($eventId, EventDomainObject::class); $questions = $this->questionRepository - ->loadRelation(TicketDomainObject::class) + ->loadRelation(ProductDomainObject::class) ->findById($questionId); return $this->resourceResponse(QuestionResource::class, $questions); diff --git a/backend/app/Http/Actions/Questions/GetQuestionsAction.php b/backend/app/Http/Actions/Questions/GetQuestionsAction.php index fa9fe3a4ae..4a234442c0 100644 --- a/backend/app/Http/Actions/Questions/GetQuestionsAction.php +++ b/backend/app/Http/Actions/Questions/GetQuestionsAction.php @@ -3,8 +3,8 @@ namespace HiEvents\Http\Actions\Questions; use HiEvents\DomainObjects\EventDomainObject; -use HiEvents\DomainObjects\TicketDomainObject; -use HiEvents\DomainObjects\TicketPriceDomainObject; +use HiEvents\DomainObjects\ProductDomainObject; +use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\Http\Actions\BaseAction; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\QuestionRepositoryInterface; @@ -27,8 +27,8 @@ public function __invoke(Request $request, int $eventId): JsonResponse $questions = $this->questionRepository ->loadRelation( - new Relationship(TicketDomainObject::class, [ - new Relationship(TicketPriceDomainObject::class) + new Relationship(ProductDomainObject::class, [ + new Relationship(ProductPriceDomainObject::class) ]) ) ->findByEventId($eventId); diff --git a/backend/app/Http/Actions/Questions/GetQuestionsPublicAction.php b/backend/app/Http/Actions/Questions/GetQuestionsPublicAction.php index 8fe8250251..8e718f2846 100644 --- a/backend/app/Http/Actions/Questions/GetQuestionsPublicAction.php +++ b/backend/app/Http/Actions/Questions/GetQuestionsPublicAction.php @@ -3,7 +3,7 @@ namespace HiEvents\Http\Actions\Questions; use HiEvents\DomainObjects\Generated\QuestionDomainObjectAbstract; -use HiEvents\DomainObjects\TicketDomainObject; +use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\Http\Actions\BaseAction; use HiEvents\Repository\Interfaces\QuestionRepositoryInterface; use HiEvents\Resources\Question\QuestionResourcePublic; @@ -22,7 +22,7 @@ public function __construct(QuestionRepositoryInterface $questionRepository) public function __invoke(Request $request, int $eventId): JsonResponse { $questions = $this->questionRepository - ->loadRelation(TicketDomainObject::class) + ->loadRelation(ProductDomainObject::class) ->findWhere([ QuestionDomainObjectAbstract::EVENT_ID => $eventId, QuestionDomainObjectAbstract::IS_HIDDEN => false, diff --git a/backend/app/Http/Actions/Questions/SortQuestionsAction.php b/backend/app/Http/Actions/Questions/SortQuestionsAction.php index 0089ffc244..2394f3deac 100644 --- a/backend/app/Http/Actions/Questions/SortQuestionsAction.php +++ b/backend/app/Http/Actions/Questions/SortQuestionsAction.php @@ -5,7 +5,7 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\Questions\SortQuestionsRequest; -use HiEvents\Services\Handlers\Question\SortQuestionsHandler; +use HiEvents\Services\Application\Handlers\Question\SortQuestionsHandler; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; use Symfony\Component\Routing\Exception\ResourceNotFoundException; diff --git a/backend/app/Http/Actions/Reports/GetReportAction.php b/backend/app/Http/Actions/Reports/GetReportAction.php new file mode 100644 index 0000000000..5fa1596ac3 --- /dev/null +++ b/backend/app/Http/Actions/Reports/GetReportAction.php @@ -0,0 +1,61 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $this->validateDateRange($request); + + if (!in_array($reportType, ReportTypes::valuesArray(), true)) { + throw new BadRequestHttpException('Invalid report type.'); + } + + $reportData = $this->reportHandler->handle( + reportData: new GetReportDTO( + eventId: $eventId, + reportType: ReportTypes::from($reportType), + startDate: $request->validated('start_date'), + endDate: $request->validated('end_date'), + ), + ); + + return $this->jsonResponse($reportData); + } + + /** + * @throws ValidationException + */ + private function validateDateRange(GetReportRequest $request): void + { + $startDate = $request->validated('start_date'); + $endDate = $request->validated('end_date'); + + $diffInDays = Carbon::parse($startDate)->diffInDays(Carbon::parse($endDate)); + + if ($diffInDays > 370) { + throw ValidationException::withMessages(['start_date' => 'Date range must be less than 370 days.']); + } + } +} diff --git a/backend/app/Http/Actions/TaxesAndFees/CreateTaxOrFeeAction.php b/backend/app/Http/Actions/TaxesAndFees/CreateTaxOrFeeAction.php index 77016a4ae5..228615d5b1 100644 --- a/backend/app/Http/Actions/TaxesAndFees/CreateTaxOrFeeAction.php +++ b/backend/app/Http/Actions/TaxesAndFees/CreateTaxOrFeeAction.php @@ -7,8 +7,8 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\TaxOrFee\CreateTaxOrFeeRequest; use HiEvents\Resources\Tax\TaxAndFeeResource; -use HiEvents\Services\Handlers\TaxAndFee\CreateTaxOrFeeHandler; -use HiEvents\Services\Handlers\TaxAndFee\DTO\UpsertTaxDTO; +use HiEvents\Services\Application\Handlers\TaxAndFee\CreateTaxOrFeeHandler; +use HiEvents\Services\Application\Handlers\TaxAndFee\DTO\UpsertTaxDTO; use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; diff --git a/backend/app/Http/Actions/TaxesAndFees/DeleteTaxOrFeeAction.php b/backend/app/Http/Actions/TaxesAndFees/DeleteTaxOrFeeAction.php index 8bb4fe4e5f..5589438146 100644 --- a/backend/app/Http/Actions/TaxesAndFees/DeleteTaxOrFeeAction.php +++ b/backend/app/Http/Actions/TaxesAndFees/DeleteTaxOrFeeAction.php @@ -5,8 +5,8 @@ use HiEvents\DomainObjects\TaxAndFeesDomainObject; use HiEvents\Exceptions\ResourceConflictException; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Services\Handlers\TaxAndFee\DeleteTaxHandler; -use HiEvents\Services\Handlers\TaxAndFee\DTO\DeleteTaxDTO; +use HiEvents\Services\Application\Handlers\TaxAndFee\DeleteTaxHandler; +use HiEvents\Services\Application\Handlers\TaxAndFee\DTO\DeleteTaxDTO; use Illuminate\Http\Response; use Throwable; diff --git a/backend/app/Http/Actions/TaxesAndFees/EditTaxOrFeeAction.php b/backend/app/Http/Actions/TaxesAndFees/EditTaxOrFeeAction.php index e6957bd8cf..2aeaa2fe2a 100644 --- a/backend/app/Http/Actions/TaxesAndFees/EditTaxOrFeeAction.php +++ b/backend/app/Http/Actions/TaxesAndFees/EditTaxOrFeeAction.php @@ -7,8 +7,8 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\TaxOrFee\CreateTaxOrFeeRequest; use HiEvents\Resources\Tax\TaxAndFeeResource; -use HiEvents\Services\Handlers\TaxAndFee\DTO\UpsertTaxDTO; -use HiEvents\Services\Handlers\TaxAndFee\EditTaxHandler; +use HiEvents\Services\Application\Handlers\TaxAndFee\DTO\UpsertTaxDTO; +use HiEvents\Services\Application\Handlers\TaxAndFee\EditTaxHandler; use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; diff --git a/backend/app/Http/Actions/Tickets/GetTicketAction.php b/backend/app/Http/Actions/Tickets/GetTicketAction.php deleted file mode 100644 index be28a9f4bc..0000000000 --- a/backend/app/Http/Actions/Tickets/GetTicketAction.php +++ /dev/null @@ -1,37 +0,0 @@ -ticketRepository = $ticketRepository; - } - - public function __invoke(int $eventId, int $ticketId): JsonResponse - { - $this->isActionAuthorized($eventId, EventDomainObject::class); - - return $this->resourceResponse(TicketResource::class, $this->ticketRepository - ->loadRelation(TaxAndFeesDomainObject::class) - ->loadRelation(TicketPriceDomainObject::class) - ->findFirstWhere([ - TicketDomainObjectAbstract::EVENT_ID => $eventId, - TicketDomainObjectAbstract::ID => $ticketId, - ])); - } -} diff --git a/backend/app/Http/Actions/Users/CancelEmailChangeAction.php b/backend/app/Http/Actions/Users/CancelEmailChangeAction.php index 1345197184..6675abffdc 100644 --- a/backend/app/Http/Actions/Users/CancelEmailChangeAction.php +++ b/backend/app/Http/Actions/Users/CancelEmailChangeAction.php @@ -5,8 +5,8 @@ use HiEvents\DomainObjects\UserDomainObject; use HiEvents\Http\Actions\BaseAction; use HiEvents\Resources\User\UserResource; -use HiEvents\Services\Handlers\User\CancelEmailChangeHandler; -use HiEvents\Services\Handlers\User\DTO\CancelEmailChangeDTO; +use HiEvents\Services\Application\Handlers\User\CancelEmailChangeHandler; +use HiEvents\Services\Application\Handlers\User\DTO\CancelEmailChangeDTO; use Illuminate\Http\JsonResponse; class CancelEmailChangeAction extends BaseAction diff --git a/backend/app/Http/Actions/Users/ConfirmEmailAddressAction.php b/backend/app/Http/Actions/Users/ConfirmEmailAddressAction.php index 28666b7579..86c59e8871 100644 --- a/backend/app/Http/Actions/Users/ConfirmEmailAddressAction.php +++ b/backend/app/Http/Actions/Users/ConfirmEmailAddressAction.php @@ -4,8 +4,8 @@ use HiEvents\DomainObjects\UserDomainObject; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Services\Handlers\User\ConfirmEmailAddressHandler; -use HiEvents\Services\Handlers\User\DTO\ConfirmEmailChangeDTO; +use HiEvents\Services\Application\Handlers\User\ConfirmEmailAddressHandler; +use HiEvents\Services\Application\Handlers\User\DTO\ConfirmEmailChangeDTO; use HiEvents\Services\Infrastructure\Encryption\Exception\DecryptionFailedException; use HiEvents\Services\Infrastructure\Encryption\Exception\EncryptedPayloadExpiredException; use Illuminate\Http\JsonResponse; diff --git a/backend/app/Http/Actions/Users/ConfirmEmailChangeAction.php b/backend/app/Http/Actions/Users/ConfirmEmailChangeAction.php index 9bb8f39af7..e443871b8a 100644 --- a/backend/app/Http/Actions/Users/ConfirmEmailChangeAction.php +++ b/backend/app/Http/Actions/Users/ConfirmEmailChangeAction.php @@ -6,8 +6,8 @@ use HiEvents\Exceptions\ResourceConflictException; use HiEvents\Http\Actions\BaseAction; use HiEvents\Resources\User\UserResource; -use HiEvents\Services\Handlers\User\ConfirmEmailChangeHandler; -use HiEvents\Services\Handlers\User\DTO\ConfirmEmailChangeDTO; +use HiEvents\Services\Application\Handlers\User\ConfirmEmailChangeHandler; +use HiEvents\Services\Application\Handlers\User\DTO\ConfirmEmailChangeDTO; use HiEvents\Services\Infrastructure\Encryption\Exception\DecryptionFailedException; use HiEvents\Services\Infrastructure\Encryption\Exception\EncryptedPayloadExpiredException; use Illuminate\Http\JsonResponse; diff --git a/backend/app/Http/Actions/Users/CreateUserAction.php b/backend/app/Http/Actions/Users/CreateUserAction.php index a1610010da..072273fa6e 100644 --- a/backend/app/Http/Actions/Users/CreateUserAction.php +++ b/backend/app/Http/Actions/Users/CreateUserAction.php @@ -10,8 +10,8 @@ use HiEvents\Http\Request\User\CreateUserRequest; use HiEvents\Http\ResponseCodes; use HiEvents\Resources\User\UserResource; -use HiEvents\Services\Handlers\User\CreateUserHandler; -use HiEvents\Services\Handlers\User\DTO\CreateUserDTO; +use HiEvents\Services\Application\Handlers\User\CreateUserHandler; +use HiEvents\Services\Application\Handlers\User\DTO\CreateUserDTO; use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; use Throwable; diff --git a/backend/app/Http/Actions/Users/ResendEmailConfirmationAction.php b/backend/app/Http/Actions/Users/ResendEmailConfirmationAction.php index 74cd578791..6035f7e169 100644 --- a/backend/app/Http/Actions/Users/ResendEmailConfirmationAction.php +++ b/backend/app/Http/Actions/Users/ResendEmailConfirmationAction.php @@ -3,7 +3,7 @@ namespace HiEvents\Http\Actions\Users; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Services\Handlers\User\ResendEmailConfirmationHandler; +use HiEvents\Services\Application\Handlers\User\ResendEmailConfirmationHandler; use Illuminate\Http\Response; class ResendEmailConfirmationAction extends BaseAction diff --git a/backend/app/Http/Actions/Users/UpdateMeAction.php b/backend/app/Http/Actions/Users/UpdateMeAction.php index 6995580524..4c1d442c43 100644 --- a/backend/app/Http/Actions/Users/UpdateMeAction.php +++ b/backend/app/Http/Actions/Users/UpdateMeAction.php @@ -6,8 +6,8 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\User\UpdateMeRequest; use HiEvents\Resources\User\UserResource; -use HiEvents\Services\Handlers\User\DTO\UpdateMeDTO; -use HiEvents\Services\Handlers\User\UpdateMeHandler; +use HiEvents\Services\Application\Handlers\User\DTO\UpdateMeDTO; +use HiEvents\Services\Application\Handlers\User\UpdateMeHandler; use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; diff --git a/backend/app/Http/Actions/Users/UpdateUserAction.php b/backend/app/Http/Actions/Users/UpdateUserAction.php index 6be10ac55f..dae41bb6b2 100644 --- a/backend/app/Http/Actions/Users/UpdateUserAction.php +++ b/backend/app/Http/Actions/Users/UpdateUserAction.php @@ -8,8 +8,8 @@ use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\User\UpdateUserRequest; use HiEvents\Resources\User\UserResource; -use HiEvents\Services\Handlers\User\DTO\UpdateUserDTO; -use HiEvents\Services\Handlers\User\UpdateUserHandler; +use HiEvents\Services\Application\Handlers\User\DTO\UpdateUserDTO; +use HiEvents\Services\Application\Handlers\User\UpdateUserHandler; use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; use Throwable; diff --git a/backend/app/Http/Actions/Webhooks/CreateWebhookAction.php b/backend/app/Http/Actions/Webhooks/CreateWebhookAction.php new file mode 100644 index 0000000000..7d01420015 --- /dev/null +++ b/backend/app/Http/Actions/Webhooks/CreateWebhookAction.php @@ -0,0 +1,42 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $webhook = $this->createWebhookHandler->handle( + new CreateWebhookDTO( + url: $request->validated('url'), + eventTypes: $request->validated('event_types'), + eventId: $eventId, + userId: $this->getAuthenticatedUser()->getId(), + accountId: $this->getAuthenticatedAccountId(), + status: WebhookStatus::fromName($request->validated('status')), + ) + ); + + return $this->resourceResponse( + resource: WebhookResource::class, + data: $webhook + ); + } +} diff --git a/backend/app/Http/Actions/Webhooks/DeleteWebhookAction.php b/backend/app/Http/Actions/Webhooks/DeleteWebhookAction.php new file mode 100644 index 0000000000..508c0fa3d0 --- /dev/null +++ b/backend/app/Http/Actions/Webhooks/DeleteWebhookAction.php @@ -0,0 +1,29 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $this->deleteWebhookHandler->handle( + $eventId, + $webhookId, + ); + + return $this->deletedResponse(); + } +} diff --git a/backend/app/Http/Actions/Webhooks/EditWebhookAction.php b/backend/app/Http/Actions/Webhooks/EditWebhookAction.php new file mode 100644 index 0000000000..32798d88f9 --- /dev/null +++ b/backend/app/Http/Actions/Webhooks/EditWebhookAction.php @@ -0,0 +1,43 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $webhook = $this->editWebhookHandler->handle( + new EditWebhookDTO( + webhookId: $webhookId, + url: $request->validated('url'), + eventTypes: $request->validated('event_types'), + eventId: $eventId, + userId: $this->getAuthenticatedUser()->getId(), + accountId: $this->getAuthenticatedAccountId(), + status: WebhookStatus::fromName($request->validated('status')), + ) + ); + + return $this->resourceResponse( + resource: WebhookResource::class, + data: $webhook + ); + } +} diff --git a/backend/app/Http/Actions/Webhooks/GetWebhookAction.php b/backend/app/Http/Actions/Webhooks/GetWebhookAction.php new file mode 100644 index 0000000000..6c78fd9814 --- /dev/null +++ b/backend/app/Http/Actions/Webhooks/GetWebhookAction.php @@ -0,0 +1,33 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $webhook = $this->getWebhookHandler->handle( + eventId: $eventId, + webhookId: $webhookId + ); + + return $this->resourceResponse( + resource: WebhookResource::class, + data: $webhook + ); + } +} diff --git a/backend/app/Http/Actions/Webhooks/GetWebhookLogsAction.php b/backend/app/Http/Actions/Webhooks/GetWebhookLogsAction.php new file mode 100644 index 0000000000..e4e67f1541 --- /dev/null +++ b/backend/app/Http/Actions/Webhooks/GetWebhookLogsAction.php @@ -0,0 +1,38 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $webhookLogs = $this->getWebhookLogsHandler->handle( + eventId: $eventId, + webhookId: $webhookId, + ); + + $webhookLogs = $webhookLogs->sortBy(function (WebhookLogDomainObject $webhookLog) { + return $webhookLog->getId(); + }, SORT_REGULAR, true); + + return $this->resourceResponse( + resource: WebhookLogResource::class, + data: $webhookLogs + ); + } +} diff --git a/backend/app/Http/Actions/Webhooks/GetWebhooksAction.php b/backend/app/Http/Actions/Webhooks/GetWebhooksAction.php new file mode 100644 index 0000000000..8c136ab727 --- /dev/null +++ b/backend/app/Http/Actions/Webhooks/GetWebhooksAction.php @@ -0,0 +1,33 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $webhooks = $this->getWebhooksHandler->handler( + accountId: $this->getAuthenticatedAccountId(), + eventId: $eventId + ); + + return $this->resourceResponse( + resource: WebhookResource::class, + data: $webhooks + ); + } +} diff --git a/backend/app/Http/Request/Account/CreateAccountRequest.php b/backend/app/Http/Request/Account/CreateAccountRequest.php index 02f9abde35..67578c8dd1 100644 --- a/backend/app/Http/Request/Account/CreateAccountRequest.php +++ b/backend/app/Http/Request/Account/CreateAccountRequest.php @@ -24,6 +24,7 @@ public function rules(): array 'timezone' => ['timezone:all'], 'currency_code' => [Rule::in(array_values($currencies))], 'locale' => ['nullable', Rule::in(Locale::getSupportedLocales())], + 'invite_token' => ['nullable', 'string'], ]; } } diff --git a/backend/app/Http/Request/Attendee/CreateAttendeeRequest.php b/backend/app/Http/Request/Attendee/CreateAttendeeRequest.php index 880c188145..3ff1502dfe 100644 --- a/backend/app/Http/Request/Attendee/CreateAttendeeRequest.php +++ b/backend/app/Http/Request/Attendee/CreateAttendeeRequest.php @@ -12,8 +12,8 @@ class CreateAttendeeRequest extends BaseRequest public function rules(): array { return [ - 'ticket_id' => ['int', 'required'], - 'ticket_price_id' => ['int', 'nullable', 'required'], + 'product_id' => ['int', 'required'], + 'product_price_id' => ['int', 'nullable', 'required'], 'email' => ['required', 'email'], 'first_name' => 'string|required', 'last_name' => 'string', diff --git a/backend/app/Http/Request/Attendee/EditAttendeeRequest.php b/backend/app/Http/Request/Attendee/EditAttendeeRequest.php index 10cf0ae4ac..780c42a80b 100644 --- a/backend/app/Http/Request/Attendee/EditAttendeeRequest.php +++ b/backend/app/Http/Request/Attendee/EditAttendeeRequest.php @@ -13,8 +13,9 @@ public function rules(): array 'email' => RulesHelper::REQUIRED_EMAIL, 'first_name' => RulesHelper::REQUIRED_STRING, 'last_name' => RulesHelper::REQUIRED_STRING, - 'ticket_id' => RulesHelper::REQUIRED_NUMERIC, - 'ticket_price_id' => RulesHelper::REQUIRED_NUMERIC, + 'product_id' => RulesHelper::REQUIRED_NUMERIC, + 'product_price_id' => RulesHelper::REQUIRED_NUMERIC, + 'notes' => RulesHelper::OPTIONAL_TEXT_MEDIUM_LENGTH, ]; } @@ -25,10 +26,11 @@ public function messages(): array 'email.email' => __('Email must be a valid email address'), 'first_name.required' => __('First name is required'), 'last_name.required' => __('Last name is required'), - 'ticket_id.required' => __('Ticket is required'), - 'ticket_price_id.required' => __('Ticket price is required'), - 'ticket_id.numeric' => '', - 'ticket_price_id.numeric' => '', + 'product_id.required' => __('Product is required'), + 'product_price_id.required' => __('Product price is required'), + 'product_id.numeric' => '', + 'product_price_id.numeric' => '', + 'notes.max' => __('Notes must be less than 2000 characters'), ]; } } diff --git a/backend/app/Http/Request/CapacityAssigment/UpsertCapacityAssignmentRequest.php b/backend/app/Http/Request/CapacityAssigment/UpsertCapacityAssignmentRequest.php index 0873bfac27..008000d444 100644 --- a/backend/app/Http/Request/CapacityAssigment/UpsertCapacityAssignmentRequest.php +++ b/backend/app/Http/Request/CapacityAssigment/UpsertCapacityAssignmentRequest.php @@ -15,14 +15,14 @@ public function rules(): array 'name' => RulesHelper::REQUIRED_STRING, 'capacity' => ['nullable', 'numeric', 'min:1'], 'status' => ['required', Rule::in(CapacityAssignmentStatus::valuesArray())], - 'ticket_ids' => ['required', 'array'], + 'product_ids' => ['required', 'array'], ]; } public function messages(): array { return [ - 'ticket_ids.required' => __('Please select at least one ticket.'), + 'product_ids.required' => __('Please select at least one product.'), ]; } } diff --git a/backend/app/Http/Request/CheckInList/CreateAttendeeCheckInPublicRequest.php b/backend/app/Http/Request/CheckInList/CreateAttendeeCheckInPublicRequest.php index a95e73ea7d..06a291d96e 100644 --- a/backend/app/Http/Request/CheckInList/CreateAttendeeCheckInPublicRequest.php +++ b/backend/app/Http/Request/CheckInList/CreateAttendeeCheckInPublicRequest.php @@ -2,15 +2,18 @@ namespace HiEvents\Http\Request\CheckInList; +use HiEvents\DomainObjects\Enums\AttendeeCheckInActionType; use HiEvents\Http\Request\BaseRequest; +use Illuminate\Validation\Rule; class CreateAttendeeCheckInPublicRequest extends BaseRequest { public function rules(): array { return [ - 'attendee_public_ids' => ['required', 'array'], - 'attendee_public_ids.*' => ['required', 'string'], + 'attendees' => ['required', 'array'], + 'attendees.*.public_id' => ['required', 'string'], + 'attendees.*.action' => ['required', 'string', Rule::in(AttendeeCheckInActionType::valuesArray())], ]; } } diff --git a/backend/app/Http/Request/CheckInList/UpsertCheckInListRequest.php b/backend/app/Http/Request/CheckInList/UpsertCheckInListRequest.php index 8405521d2a..06372e6760 100644 --- a/backend/app/Http/Request/CheckInList/UpsertCheckInListRequest.php +++ b/backend/app/Http/Request/CheckInList/UpsertCheckInListRequest.php @@ -14,7 +14,7 @@ public function rules(): array 'description' => ['nullable', 'string', 'max:255'], 'expires_at' => ['nullable', 'date'], 'activates_at' => ['nullable', 'date'], - 'ticket_ids' => ['required', 'array', 'min:1'], + 'product_ids' => ['required', 'array', 'min:1'], ]; } @@ -32,7 +32,7 @@ public function withValidator($validator): void public function messages(): array { return [ - 'ticket_ids.required' => __('Please select at least one ticket.'), + '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 70e0420abd..f202623594 100644 --- a/backend/app/Http/Request/Event/DuplicateEventRequest.php +++ b/backend/app/Http/Request/Event/DuplicateEventRequest.php @@ -14,13 +14,14 @@ public function rules(): array $eventValidations = $this->minimalRules(); $duplicateValidations = [ - 'duplicate_tickets' => ['boolean', 'required'], + 'duplicate_products' => ['boolean', 'required'], 'duplicate_questions' => ['boolean', 'required'], 'duplicate_settings' => ['boolean', 'required'], 'duplicate_promo_codes' => ['boolean', 'required'], 'duplicate_capacity_assignments' => ['boolean', 'required'], 'duplicate_check_in_lists' => ['boolean', 'required'], 'duplicate_event_cover_image' => ['boolean', 'required'], + 'duplicate_webhooks' => ['boolean', 'required'], ]; return array_merge($eventValidations, $duplicateValidations); diff --git a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php index 4b1ad92264..74416cbaf4 100644 --- a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php +++ b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php @@ -3,6 +3,7 @@ namespace HiEvents\Http\Request\EventSettings; use HiEvents\DomainObjects\Enums\HomepageBackgroundType; +use HiEvents\DomainObjects\Enums\PaymentProviders; use HiEvents\DomainObjects\Enums\PriceDisplayMode; use HiEvents\Http\Request\BaseRequest; use HiEvents\Validators\Rules\RulesHelper; @@ -56,6 +57,24 @@ public function rules(): array 'price_display_mode' => [Rule::in(PriceDisplayMode::valuesArray())], 'hide_getting_started_page' => ['boolean'], + + // Payment settings + 'payment_providers' => ['array'], + 'payment_providers.*' => ['string', Rule::in(PaymentProviders::valuesArray())], + 'offline_payment_instructions' => ['string', 'nullable', Rule::requiredIf(fn() => in_array(PaymentProviders::OFFLINE->name, $this->input('payment_providers', []), true))], + 'allow_orders_awaiting_offline_payment_to_check_in' => ['boolean'], + + // Invoice settings + 'enable_invoicing' => ['boolean'], + 'invoice_label' => ['nullable', 'string', 'max:50'], + 'invoice_prefix' => ['nullable', 'string', 'max:10', 'regex:/^[A-Za-z0-9\-]*$/'], + 'invoice_start_number' => ['nullable', 'integer', 'min:1'], + 'require_billing_address' => ['boolean'], + 'organization_name' => ['required_if:enable_invoicing,true', 'string', 'max:255', 'nullable'], + 'organization_address' => ['required_if:enable_invoicing,true', 'string', 'max:255', 'nullable'], + 'invoice_tax_details' => ['nullable', 'string'], + 'invoice_notes' => ['nullable', 'string'], + 'invoice_payment_terms_days' => ['nullable', 'integer', 'gte:0', 'lte:1000'], ]; } @@ -69,14 +88,24 @@ public function messages(): array 'homepage_text_color' => $colorMessage, 'homepage_button_color' => $colorMessage, 'homepage_link_color' => $colorMessage, - 'homepage_ticket_widget_background_color' => $colorMessage, - 'homepage_ticket_widget_text_color' => $colorMessage, + 'homepage_product_widget_background_color' => $colorMessage, + 'homepage_product_widget_text_color' => $colorMessage, 'location_details.address_line_1.required_with' => __('The address line 1 field is required'), 'location_details.city.required_with' => __('The city field is required'), 'location_details.zip_or_postal_code.required_with' => __('The zip or postal code field is required'), 'location_details.country.required_with' => __('The country field is required'), 'location_details.country.max' => __('The country field should be a 2 character ISO 3166 code'), 'price_display_mode.in' => 'The price display mode must be either inclusive or exclusive.', + + // Payment messages + 'payment_providers.*.in' => __('Invalid payment provider selected.'), + 'offline_payment_instructions.required' => __('Payment instructions are required when offline payments are enabled.'), + + // Invoice messages + 'invoice_prefix.regex' => __('The invoice prefix may only contain letters, numbers, and hyphens.'), + 'organization_name.required_if' => __('The organization name is required when invoicing is enabled.'), + 'organization_address.required_if' => __('The organization address is required when invoicing is enabled.'), + 'invoice_start_number.min' => __('The invoice start number must be at least 1.'), ]; } } diff --git a/backend/app/Http/Request/Message/SendMessageRequest.php b/backend/app/Http/Request/Message/SendMessageRequest.php index 35f69c7b71..c6ebba1390 100644 --- a/backend/app/Http/Request/Message/SendMessageRequest.php +++ b/backend/app/Http/Request/Message/SendMessageRequest.php @@ -3,6 +3,7 @@ namespace HiEvents\Http\Request\Message; use HiEvents\DomainObjects\Enums\MessageTypeEnum; +use HiEvents\DomainObjects\Status\OrderStatus; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rules\In; @@ -15,20 +16,25 @@ public function rules(): array 'message' => 'required|string|max:5000', 'message_type' => [new In(MessageTypeEnum::valuesArray()), 'required'], 'is_test' => 'boolean', - 'attendee_ids' => 'max:50,array|required_if:message_type,' . MessageTypeEnum::ATTENDEE->name, + 'attendee_ids' => 'max:50,array|required_if:message_type,' . MessageTypeEnum::INDIVIDUAL_ATTENDEES->name, 'attendee_ids.*' => 'integer', - 'ticket_ids' => ['array', 'required_if:message_type,' . MessageTypeEnum::TICKET->name], - 'order_id' => 'integer|required_if:message_type,' . MessageTypeEnum::ORDER->name, - 'ticket_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.*' => 'integer', + 'order_statuses.*' => [ + 'required_if:message_type,' . MessageTypeEnum::ORDER_OWNERS_WITH_PRODUCT->name, + new In([OrderStatus::COMPLETED->name, OrderStatus::AWAITING_OFFLINE_PAYMENT->name]), + ], ]; } 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 ticket, or to all event attendees.' + 'To message more attendees, you can send to attendees with a specific product, or to all event attendees.' ]; } } diff --git a/backend/app/Http/Request/Order/EditOrderRequest.php b/backend/app/Http/Request/Order/EditOrderRequest.php new file mode 100644 index 0000000000..4c291eb405 --- /dev/null +++ b/backend/app/Http/Request/Order/EditOrderRequest.php @@ -0,0 +1,19 @@ + RulesHelper::REQUIRED_EMAIL, + 'first_name' => RulesHelper::REQUIRED_STRING, + 'last_name' => RulesHelper::REQUIRED_STRING, + 'notes' => RulesHelper::OPTIONAL_TEXT_MEDIUM_LENGTH, + ]; + } +} diff --git a/backend/app/Http/Request/Product/SortProductsRequest.php b/backend/app/Http/Request/Product/SortProductsRequest.php new file mode 100644 index 0000000000..5c78b486b4 --- /dev/null +++ b/backend/app/Http/Request/Product/SortProductsRequest.php @@ -0,0 +1,19 @@ + 'array|required', + 'sorted_categories.*.product_category_id' => 'integer|required', + 'sorted_categories.*.sorted_products' => 'array', + 'sorted_categories.*.sorted_products.*.id' => 'integer|required', + 'sorted_categories.*.sorted_products.*.order' => 'integer', + ]; + } +} diff --git a/backend/app/Http/Request/Ticket/UpsertTicketRequest.php b/backend/app/Http/Request/Product/UpsertProductRequest.php similarity index 76% rename from backend/app/Http/Request/Ticket/UpsertTicketRequest.php rename to backend/app/Http/Request/Product/UpsertProductRequest.php index 4eff2df352..eb8f91b52a 100644 --- a/backend/app/Http/Request/Ticket/UpsertTicketRequest.php +++ b/backend/app/Http/Request/Product/UpsertProductRequest.php @@ -2,14 +2,15 @@ declare(strict_types=1); -namespace HiEvents\Http\Request\Ticket; +namespace HiEvents\Http\Request\Product; -use HiEvents\DomainObjects\Enums\TicketType; +use HiEvents\DomainObjects\Enums\ProductPriceType; +use HiEvents\DomainObjects\Enums\ProductType; use HiEvents\Http\Request\BaseRequest; use HiEvents\Validators\Rules\RulesHelper; use Illuminate\Validation\Rule; -class UpsertTicketRequest extends BaseRequest +class UpsertProductRequest extends BaseRequest { public function rules(): array { @@ -22,7 +23,7 @@ public function rules(): array 'max_per_order' => 'integer|nullable', 'prices' => ['required', 'array'], 'prices.*.price' => [...RulesHelper::MONEY, 'required'], - 'prices.*.label' => ['nullable', ...RulesHelper::STRING, 'required_if:type,' . TicketType::TIERED->name], + 'prices.*.label' => ['nullable', ...RulesHelper::STRING, 'required_if:type,' . ProductPriceType::TIERED->name], 'prices.*.sale_start_date' => ['date', 'nullable', 'after:sale_start_date'], 'prices.*.sale_end_date' => 'date|nullable|after:prices.*.sale_start_date', 'prices.*.initial_quantity_available' => ['integer', 'nullable', 'min:0'], @@ -36,8 +37,10 @@ public function rules(): array 'start_collapsed' => 'boolean', 'show_quantity_remaining' => 'boolean', 'is_hidden_without_promo_code' => 'boolean', - 'type' => ['required', Rule::in(TicketType::valuesArray())], + 'type' => ['required', Rule::in(ProductPriceType::valuesArray())], + 'product_type' => ['required', Rule::in(ProductType::valuesArray())], 'tax_and_fee_ids' => 'array', + 'product_category_id' => ['required', 'integer'], ]; } @@ -47,7 +50,8 @@ public function messages(): array 'sale_end_date.after' => __('The sale end date must be after the sale start date.'), 'prices.*.sale_end_date.after' => __('The sale end date must be after the sale start date.'), 'prices.*.sale_end_date.date' => __('The sale end date must be a valid date.'), - 'prices.*.sale_start_date.after' => __('The sale start date must be after the ticket sale start date.'), + 'prices.*.sale_start_date.after' => __('The sale start date must be after the product sale start date.'), + 'product_category_id.required' => __('You must select a product category.'), ]; } } diff --git a/backend/app/Http/Request/ProductCategory/UpsertProductCategoryRequest.php b/backend/app/Http/Request/ProductCategory/UpsertProductCategoryRequest.php new file mode 100644 index 0000000000..87a7ccdb97 --- /dev/null +++ b/backend/app/Http/Request/ProductCategory/UpsertProductCategoryRequest.php @@ -0,0 +1,18 @@ + ['string', 'required', 'max:50'], + 'description' => ['string', 'max:255', 'nullable'], + 'is_hidden' => ['boolean', 'required'], + 'no_products_message' => ['string', 'max:255', 'nullable'], + ]; + } +} diff --git a/backend/app/Http/Request/PromoCode/CreateUpdatePromoCodeRequest.php b/backend/app/Http/Request/PromoCode/CreateUpdatePromoCodeRequest.php index 328fd0cbd1..5e381f6120 100644 --- a/backend/app/Http/Request/PromoCode/CreateUpdatePromoCodeRequest.php +++ b/backend/app/Http/Request/PromoCode/CreateUpdatePromoCodeRequest.php @@ -12,7 +12,7 @@ public function rules(): array { return [ 'code' => 'min:2|string|required|max:50', - 'applicable_ticket_ids' => 'array', + 'applicable_product_ids' => 'array', 'discount' => [ 'required_if:discount_type,PERCENTAGE,FIXED', 'numeric', diff --git a/backend/app/Http/Request/Questions/UpsertQuestionRequest.php b/backend/app/Http/Request/Questions/UpsertQuestionRequest.php index e696d15e3e..a383a0036d 100644 --- a/backend/app/Http/Request/Questions/UpsertQuestionRequest.php +++ b/backend/app/Http/Request/Questions/UpsertQuestionRequest.php @@ -15,9 +15,9 @@ public function rules(): array 'title' => ['string', 'required'], 'description' => ['string', 'nullable', 'max:10000'], 'type' => ['required', Rule::in(QuestionTypeEnum::valuesArray())], - 'ticket_ids' => ['array', 'required_if:belongs_to,TICKET'], + 'product_ids' => ['array', 'required_if:belongs_to,PRODUCT'], 'belongs_to' => [ - ['required', Rule::in([QuestionBelongsTo::TICKET->name, QuestionBelongsTo::ORDER->name])], + ['required', Rule::in([QuestionBelongsTo::PRODUCT->name, QuestionBelongsTo::ORDER->name])], ], 'options' => 'max:2000|required_if:type,CHECKBOX,RADIO', 'required' => 'required|boolean', @@ -28,7 +28,7 @@ public function rules(): array public function messages(): array { return [ - 'ticket_ids.required_if' => __('Please select at least one ticket.'), + 'product_ids.required_if' => __('Please select at least one product.'), ]; } } diff --git a/backend/app/Http/Request/Report/GetReportRequest.php b/backend/app/Http/Request/Report/GetReportRequest.php new file mode 100644 index 0000000000..458a9861df --- /dev/null +++ b/backend/app/Http/Request/Report/GetReportRequest.php @@ -0,0 +1,16 @@ + 'date|before:end_date|required_with:end_date|nullable', + 'end_date' => 'date|after:start_date|required_with:start_date|nullable', + ]; + } +} diff --git a/backend/app/Http/Request/Ticket/SortTicketsRequest.php b/backend/app/Http/Request/Ticket/SortTicketsRequest.php deleted file mode 100644 index 5ca72804f9..0000000000 --- a/backend/app/Http/Request/Ticket/SortTicketsRequest.php +++ /dev/null @@ -1,16 +0,0 @@ - 'integer|required', - '*.order' => 'integer|required', - ]; - } -} diff --git a/backend/app/Http/Request/Webhook/UpsertWebhookRequest.php b/backend/app/Http/Request/Webhook/UpsertWebhookRequest.php new file mode 100644 index 0000000000..b0eb746f0c --- /dev/null +++ b/backend/app/Http/Request/Webhook/UpsertWebhookRequest.php @@ -0,0 +1,20 @@ + 'required|url', + 'event_types.*' => ['required', Rule::in(WebhookEventType::valuesArray())], + 'status' => ['nullable', Rule::in(WebhookStatus::valuesArray())], + ]; + } +} diff --git a/backend/app/Jobs/Event/SendMessagesJob.php b/backend/app/Jobs/Event/SendMessagesJob.php index eaa087dc2d..142f322fc2 100644 --- a/backend/app/Jobs/Event/SendMessagesJob.php +++ b/backend/app/Jobs/Event/SendMessagesJob.php @@ -3,8 +3,8 @@ namespace HiEvents\Jobs\Event; use HiEvents\Exceptions\UnableToSendMessageException; +use HiEvents\Services\Application\Handlers\Message\DTO\SendMessageDTO; use HiEvents\Services\Domain\Mail\SendEventEmailMessagesService; -use HiEvents\Services\Handlers\Message\DTO\SendMessageDTO; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; diff --git a/backend/app/Jobs/Order/SendOrderDetailsEmailJob.php b/backend/app/Jobs/Order/SendOrderDetailsEmailJob.php index c6fb63f948..4ca2fcf193 100644 --- a/backend/app/Jobs/Order/SendOrderDetailsEmailJob.php +++ b/backend/app/Jobs/Order/SendOrderDetailsEmailJob.php @@ -22,6 +22,6 @@ public function __construct(private readonly OrderDomainObject $order) public function handle(SendOrderDetailsService $service): void { - $service->sendOrderSummaryAndTicketEmails($this->order); + $service->sendOrderSummaryAndProductEmails($this->order); } } diff --git a/backend/app/Jobs/Order/Webhook/DispatchAttendeeWebhookJob.php b/backend/app/Jobs/Order/Webhook/DispatchAttendeeWebhookJob.php new file mode 100644 index 0000000000..fc885634f9 --- /dev/null +++ b/backend/app/Jobs/Order/Webhook/DispatchAttendeeWebhookJob.php @@ -0,0 +1,30 @@ +dispatchAttendeeWebhook( + eventType: $this->eventType, + attendeeId: $this->attendeeId, + ); + } +} diff --git a/backend/app/Jobs/Order/Webhook/DispatchCheckInWebhookJob.php b/backend/app/Jobs/Order/Webhook/DispatchCheckInWebhookJob.php new file mode 100644 index 0000000000..db5ce766b4 --- /dev/null +++ b/backend/app/Jobs/Order/Webhook/DispatchCheckInWebhookJob.php @@ -0,0 +1,30 @@ +dispatchCheckInWebhook( + eventType: $this->eventType, + attendeeCheckInId: $this->attendeeCheckInId, + ); + } +} diff --git a/backend/app/Jobs/Order/Webhook/DispatchOrderWebhookJob.php b/backend/app/Jobs/Order/Webhook/DispatchOrderWebhookJob.php new file mode 100644 index 0000000000..fa9aa4533c --- /dev/null +++ b/backend/app/Jobs/Order/Webhook/DispatchOrderWebhookJob.php @@ -0,0 +1,30 @@ +dispatchOrderWebhook( + eventType: $this->eventType, + orderId: $this->orderId, + ); + } +} diff --git a/backend/app/Jobs/Order/Webhook/DispatchProductWebhookJob.php b/backend/app/Jobs/Order/Webhook/DispatchProductWebhookJob.php new file mode 100644 index 0000000000..12e70eb65f --- /dev/null +++ b/backend/app/Jobs/Order/Webhook/DispatchProductWebhookJob.php @@ -0,0 +1,30 @@ +dispatchProductWebhook( + eventType: $this->eventType, + productId: $this->productId, + ); + } +} diff --git a/backend/app/Listeners/Order/CreateInvoiceListener.php b/backend/app/Listeners/Order/CreateInvoiceListener.php new file mode 100644 index 0000000000..827cb84f4a --- /dev/null +++ b/backend/app/Listeners/Order/CreateInvoiceListener.php @@ -0,0 +1,33 @@ +createInvoice) { + return; + } + + $order = $event->order; + + if ($order->getStatus() !== OrderStatus::AWAITING_OFFLINE_PAYMENT->name && $order->getStatus() !== OrderStatus::COMPLETED->name) { + return; + } + + $this->invoiceCreateService->createInvoiceForOrder($order->getId()); + } +} diff --git a/backend/app/Listeners/Webhook/WebhookCallEventListener.php b/backend/app/Listeners/Webhook/WebhookCallEventListener.php new file mode 100644 index 0000000000..0e7d09b15c --- /dev/null +++ b/backend/app/Listeners/Webhook/WebhookCallEventListener.php @@ -0,0 +1,31 @@ +meta['event_id'] ?? throw new RuntimeException('Event ID not found in webhook meta'); + $webhookId = $event->meta['webhook_id'] ?? throw new RuntimeException('Webhook ID not found in webhook meta'); + $eventType = $event->meta['event_type'] ?? throw new RuntimeException('Event type not found in webhook meta'); + + $this->webhookResponseHandlerService->handleResponse( + $eventId, + $webhookId, + $eventType, + $event->payload, + $event->response, + ); + } +} diff --git a/backend/app/Listeners/Webhook/WebhookCallFailedEventListener.php b/backend/app/Listeners/Webhook/WebhookCallFailedEventListener.php new file mode 100644 index 0000000000..114bf046f8 --- /dev/null +++ b/backend/app/Listeners/Webhook/WebhookCallFailedEventListener.php @@ -0,0 +1,13 @@ +handleEvent($event); + } +} diff --git a/backend/app/Listeners/Webhook/WebhookCallSucceededEventListener.php b/backend/app/Listeners/Webhook/WebhookCallSucceededEventListener.php new file mode 100644 index 0000000000..6f7c6d2770 --- /dev/null +++ b/backend/app/Listeners/Webhook/WebhookCallSucceededEventListener.php @@ -0,0 +1,13 @@ +handleEvent($event); + } +} diff --git a/backend/app/Locale.php b/backend/app/Locale.php index fd0196a629..052a77ff68 100644 --- a/backend/app/Locale.php +++ b/backend/app/Locale.php @@ -15,6 +15,7 @@ enum Locale: string case PT = 'pt'; case PT_BR = 'pt-br'; case ZH_CN = 'zh-cn'; + case VI = 'vi'; public static function getSupportedLocales(): array { diff --git a/backend/app/Mail/Attendee/AttendeeTicketMail.php b/backend/app/Mail/Attendee/AttendeeTicketMail.php index 722d91ac8a..f3afe2b3ec 100644 --- a/backend/app/Mail/Attendee/AttendeeTicketMail.php +++ b/backend/app/Mail/Attendee/AttendeeTicketMail.php @@ -6,6 +6,7 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; +use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Helper\StringHelper; use HiEvents\Helper\Url; @@ -23,6 +24,7 @@ class AttendeeTicketMail extends BaseMail { public function __construct( + private readonly OrderDomainObject $order, private readonly AttendeeDomainObject $attendee, private readonly EventDomainObject $event, private readonly EventSettingDomainObject $eventSettings, @@ -51,6 +53,7 @@ public function content(): Content 'attendee' => $this->attendee, 'eventSettings' => $this->eventSettings, 'organizer' => $this->organizer, + 'order' => $this->order, 'ticketUrl' => sprintf( Url::getFrontEndUrlFromConfig(Url::ATTENDEE_TICKET), $this->event->getId(), diff --git a/backend/app/Mail/Event/EventMessage.php b/backend/app/Mail/Event/EventMessage.php index 11a3a89211..f7807374d0 100644 --- a/backend/app/Mail/Event/EventMessage.php +++ b/backend/app/Mail/Event/EventMessage.php @@ -5,7 +5,7 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\Mail\BaseMail; -use HiEvents\Services\Handlers\Message\DTO\SendMessageDTO; +use HiEvents\Services\Application\Handlers\Message\DTO\SendMessageDTO; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; diff --git a/backend/app/Mail/Order/OrderSummary.php b/backend/app/Mail/Order/OrderSummary.php index c04bd78d96..97b041375e 100644 --- a/backend/app/Mail/Order/OrderSummary.php +++ b/backend/app/Mail/Order/OrderSummary.php @@ -2,12 +2,15 @@ namespace HiEvents\Mail\Order; +use Barryvdh\DomPDF\Facade\Pdf; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; +use HiEvents\DomainObjects\InvoiceDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Helper\Url; use HiEvents\Mail\BaseMail; +use Illuminate\Mail\Mailables\Attachment; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; @@ -21,6 +24,7 @@ public function __construct( private readonly EventDomainObject $event, private readonly OrganizerDomainObject $organizer, private readonly EventSettingDomainObject $eventSettings, + private readonly ?InvoiceDomainObject $invoice, ) { parent::__construct(); @@ -39,6 +43,7 @@ public function content(): Content return new Content( markdown: 'emails.orders.summary', with: [ + 'eventSettings' => $this->eventSettings, 'event' => $this->event, 'order' => $this->order, 'organizer' => $this->organizer, @@ -50,4 +55,26 @@ public function content(): Content ] ); } + + public function attachments(): array + { + if ($this->invoice === null) { + return []; + } + + $invoice = Pdf::loadView('invoice', [ + 'order' => $this->order, + 'event' => $this->event, + 'organizer' => $this->organizer, + 'eventSettings' => $this->eventSettings, + 'invoice' => $this->invoice, + ]); + + return [ + Attachment::fromData( + static fn() => $invoice->output(), + 'invoice.pdf', + )->withMime('application/pdf'), + ]; + } } diff --git a/backend/app/Models/Account.php b/backend/app/Models/Account.php index eb59f1b85c..210152df65 100644 --- a/backend/app/Models/Account.php +++ b/backend/app/Models/Account.php @@ -5,6 +5,7 @@ namespace HiEvents\Models; use HiEvents\DomainObjects\Enums\Role; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; class Account extends BaseModel @@ -30,4 +31,12 @@ public function usersByRole(Role $roleName): BelongsToMany { return $this->users()->wherePivot('role', '=', $roleName->name); } + + public function configuration(): BelongsTo + { + return $this->belongsTo( + related: AccountConfiguration::class, + foreignKey: 'account_configuration_id', + ); + } } diff --git a/backend/app/Models/AccountConfiguration.php b/backend/app/Models/AccountConfiguration.php new file mode 100644 index 0000000000..f76281294f --- /dev/null +++ b/backend/app/Models/AccountConfiguration.php @@ -0,0 +1,27 @@ + 'array', + ]; + } + + protected function getFillableFields(): array + { + return []; + } + + public function account(): HasMany + { + return $this->hasMany(Account::class); + } +} diff --git a/backend/app/Models/Attendee.php b/backend/app/Models/Attendee.php index 9164ce2361..476a6d1609 100644 --- a/backend/app/Models/Attendee.php +++ b/backend/app/Models/Attendee.php @@ -30,9 +30,9 @@ public function order(): BelongsTo return $this->belongsTo(Order::class); } - public function ticket(): BelongsTo + public function product(): BelongsTo { - return $this->belongsTo(Ticket::class); + return $this->belongsTo(Product::class); } public function check_in(): HasOne diff --git a/backend/app/Models/AttendeeCheckIn.php b/backend/app/Models/AttendeeCheckIn.php index be8f6f681f..50eff85135 100644 --- a/backend/app/Models/AttendeeCheckIn.php +++ b/backend/app/Models/AttendeeCheckIn.php @@ -16,10 +16,10 @@ protected function getFillableFields(): array return []; } - public function tickets(): BelongsTo + public function products(): BelongsTo { return $this->belongsTo( - related: Ticket::class, + related: Product::class, ); } diff --git a/backend/app/Models/BaseModel.php b/backend/app/Models/BaseModel.php index 6872991164..4fb60f0ac7 100644 --- a/backend/app/Models/BaseModel.php +++ b/backend/app/Models/BaseModel.php @@ -27,9 +27,15 @@ public function __construct(array $attributes = []) parent::__construct($attributes); } - abstract protected function getCastMap(): array; + protected function getCastMap(): array + { + return []; + } - abstract protected function getFillableFields(): array; + protected function getFillableFields() : array + { + return []; + } protected function getTimestampsEnabled(): bool { diff --git a/backend/app/Models/CapacityAssignment.php b/backend/app/Models/CapacityAssignment.php index 7359f9455e..87518f133b 100644 --- a/backend/app/Models/CapacityAssignment.php +++ b/backend/app/Models/CapacityAssignment.php @@ -22,11 +22,11 @@ public function event(): BelongsTo return $this->belongsTo(Event::class); } - public function tickets(): BelongsToMany + public function products(): BelongsToMany { return $this->belongsToMany( - related: Ticket::class, - table: 'ticket_capacity_assignments', + related: Product::class, + table: 'product_capacity_assignments', ); } } diff --git a/backend/app/Models/CheckInList.php b/backend/app/Models/CheckInList.php index d61c62b989..0e918372e1 100644 --- a/backend/app/Models/CheckInList.php +++ b/backend/app/Models/CheckInList.php @@ -17,11 +17,11 @@ protected function getFillableFields(): array return []; } - public function tickets(): BelongsToMany + public function products(): BelongsToMany { return $this->belongsToMany( - related: Ticket::class, - table: 'ticket_check_in_lists', + related: Product::class, + table: 'product_check_in_lists', ); } diff --git a/backend/app/Models/Event.php b/backend/app/Models/Event.php index d7be878910..4578e5a2f8 100644 --- a/backend/app/Models/Event.php +++ b/backend/app/Models/Event.php @@ -14,14 +14,24 @@ class Event extends BaseModel { use HasImages; + public function account(): BelongsTo + { + return $this->belongsTo(Account::class); + } + public function organizer(): BelongsTo { return $this->belongsTo(Organizer::class); } - public function tickets(): HasMany + public function products(): HasMany { - return $this->hasMany(Ticket::class)->orderBy('order'); + return $this->hasMany(Product::class)->orderBy('order'); + } + + public function product_categories(): HasMany + { + return $this->hasMany(ProductCategory::class)->orderBy('order'); } public function attendees(): HasMany @@ -59,6 +69,11 @@ public function event_statistics(): HasOne return $this->hasOne(EventStatistic::class); } + public function webhooks(): HasMany + { + return $this->hasMany(Webhook::class); + } + public static function boot() { parent::boot(); diff --git a/backend/app/Models/EventSetting.php b/backend/app/Models/EventSetting.php index 33d8085aec..639703bc8b 100644 --- a/backend/app/Models/EventSetting.php +++ b/backend/app/Models/EventSetting.php @@ -8,6 +8,7 @@ protected function getCastMap(): array { return [ 'location_details' => 'array', + 'payment_providers' => 'array', ]; } diff --git a/backend/app/Models/Invoice.php b/backend/app/Models/Invoice.php new file mode 100644 index 0000000000..697bd1be4b --- /dev/null +++ b/backend/app/Models/Invoice.php @@ -0,0 +1,33 @@ + 'array', + 'items' => 'array', + 'total_amount' => 'float', + 'due_date' => 'datetime', + ]; + } + + protected function getFillableFields(): array + { + return []; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } +} diff --git a/backend/app/Models/Message.php b/backend/app/Models/Message.php index 7e08f7aed7..1cac8fae37 100644 --- a/backend/app/Models/Message.php +++ b/backend/app/Models/Message.php @@ -15,7 +15,7 @@ protected function getCastMap(): array { return [ 'attendee_ids' => 'array', - 'ticket_ids' => 'array', + 'product_ids' => 'array', 'send_data' => 'array', ]; } diff --git a/backend/app/Models/Order.php b/backend/app/Models/Order.php index b4b556c9e4..f40bbf150b 100644 --- a/backend/app/Models/Order.php +++ b/backend/app/Models/Order.php @@ -33,6 +33,16 @@ public function event(): BelongsTo return $this->belongsTo(Event::class); } + public function invoices(): HasMany + { + return $this->hasMany(Invoice::class)->orderBy('created_at', 'desc'); + } + + public function order_application_fee(): HasOne + { + return $this->hasOne(OrderApplicationFee::class); + } + protected function getCastMap(): array { return [ diff --git a/backend/app/Models/OrderApplicationFee.php b/backend/app/Models/OrderApplicationFee.php new file mode 100644 index 0000000000..69b4012d37 --- /dev/null +++ b/backend/app/Models/OrderApplicationFee.php @@ -0,0 +1,25 @@ + 'array', + ]; + } + + protected function getFillableFields(): array + { + return []; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } +} diff --git a/backend/app/Models/OrderItem.php b/backend/app/Models/OrderItem.php index b1b87bd985..cd5796707f 100644 --- a/backend/app/Models/OrderItem.php +++ b/backend/app/Models/OrderItem.php @@ -36,13 +36,13 @@ protected function getFillableFields(): array return []; } - public function ticket_price(): HasOne + public function product_price(): HasOne { - return $this->hasOne(TicketPrice::class); + return $this->hasOne(ProductPrice::class); } - public function ticket(): BelongsTo + public function product(): BelongsTo { - return $this->belongsTo(Ticket::class); + return $this->belongsTo(Product::class); } } diff --git a/backend/app/Models/OrderRefund.php b/backend/app/Models/OrderRefund.php new file mode 100644 index 0000000000..ec28222c0b --- /dev/null +++ b/backend/app/Models/OrderRefund.php @@ -0,0 +1,18 @@ + 'array', + ]; + } + + protected function getFillableFields(): array + { + return []; + } +} diff --git a/backend/app/Models/Product.php b/backend/app/Models/Product.php new file mode 100644 index 0000000000..a820857821 --- /dev/null +++ b/backend/app/Models/Product.php @@ -0,0 +1,56 @@ + 'float', + ProductDomainObjectAbstract::SALES_TAX_VOLUME => 'float', + ]; + } + + protected function getFillableFields(): array + { + return []; + } + + public function questions(): BelongsToMany + { + return $this->belongsToMany(Question::class, 'product_questions'); + } + + public function product_prices(): HasMany + { + return $this->hasMany(ProductPrice::class)->orderBy('order'); + } + + public function tax_and_fees(): BelongsToMany + { + return $this->belongsToMany(TaxAndFee::class, 'product_taxes_and_fees'); + } + + public function capacity_assignments(): BelongsToMany + { + return $this->belongsToMany(CapacityAssignment::class, 'product_capacity_assignments'); + } + + public function check_in_lists(): BelongsToMany + { + return $this->belongsToMany(CheckInList::class, 'product_check_in_lists'); + } + + public function product_category(): BelongsTo + { + return $this->belongsTo(ProductCategory::class); + } +} diff --git a/backend/app/Models/ProductCategory.php b/backend/app/Models/ProductCategory.php new file mode 100644 index 0000000000..b96826a21c --- /dev/null +++ b/backend/app/Models/ProductCategory.php @@ -0,0 +1,35 @@ +hasMany(Product::class); + } +} diff --git a/backend/app/Models/TicketPrice.php b/backend/app/Models/ProductPrice.php similarity index 70% rename from backend/app/Models/TicketPrice.php rename to backend/app/Models/ProductPrice.php index 1ac6e299e1..23ab15a389 100644 --- a/backend/app/Models/TicketPrice.php +++ b/backend/app/Models/ProductPrice.php @@ -4,7 +4,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; -class TicketPrice extends BaseModel +class ProductPrice extends BaseModel { protected function getCastMap(): array { @@ -18,8 +18,8 @@ protected function getFillableFields(): array return []; } - public function ticket(): BelongsTo + public function product(): BelongsTo { - return $this->belongsTo(Ticket::class); + return $this->belongsTo(Product::class); } } diff --git a/backend/app/Models/TicketQuestion.php b/backend/app/Models/ProductQuestion.php similarity index 55% rename from backend/app/Models/TicketQuestion.php rename to backend/app/Models/ProductQuestion.php index 4c85d75c10..fee35fc522 100644 --- a/backend/app/Models/TicketQuestion.php +++ b/backend/app/Models/ProductQuestion.php @@ -2,9 +2,9 @@ namespace HiEvents\Models; -use HiEvents\DomainObjects\Generated\TicketQuestionDomainObjectAbstract; +use HiEvents\DomainObjects\Generated\ProductQuestionDomainObjectAbstract; -class TicketQuestion extends BaseModel +class ProductQuestion extends BaseModel { protected function getTimestampsEnabled(): bool { @@ -19,8 +19,8 @@ protected function getCastMap(): array protected function getFillableFields(): array { return [ - TicketQuestionDomainObjectAbstract::QUESTION_ID, - TicketQuestionDomainObjectAbstract::TICKET_ID, + ProductQuestionDomainObjectAbstract::QUESTION_ID, + ProductQuestionDomainObjectAbstract::PRODUCT_ID, ]; } } diff --git a/backend/app/Models/PromoCode.php b/backend/app/Models/PromoCode.php index d6d7545afb..1e0ad4f0c5 100644 --- a/backend/app/Models/PromoCode.php +++ b/backend/app/Models/PromoCode.php @@ -11,7 +11,7 @@ protected function getCastMap(): array return [ PromoCodeDomainObjectAbstract::DISCOUNT => 'float', PromoCodeDomainObjectAbstract::EXPIRY_DATE => 'datetime', - PromoCodeDomainObjectAbstract::APPLICABLE_TICKET_IDS => 'array', + PromoCodeDomainObjectAbstract::APPLICABLE_PRODUCT_IDS => 'array', ]; } @@ -21,7 +21,7 @@ protected function getFillableFields(): array PromoCodeDomainObjectAbstract::CODE, PromoCodeDomainObjectAbstract::DISCOUNT, PromoCodeDomainObjectAbstract::DISCOUNT_TYPE, - PromoCodeDomainObjectAbstract::APPLICABLE_TICKET_IDS, + PromoCodeDomainObjectAbstract::APPLICABLE_PRODUCT_IDS, PromoCodeDomainObjectAbstract::EXPIRY_DATE, PromoCodeDomainObjectAbstract::EVENT_ID, PromoCodeDomainObjectAbstract::MAX_ALLOWED_USAGES, diff --git a/backend/app/Models/Question.php b/backend/app/Models/Question.php index 1fa2744f8d..f540466f5a 100644 --- a/backend/app/Models/Question.php +++ b/backend/app/Models/Question.php @@ -19,10 +19,10 @@ protected function getFillableFields(): array return []; } - public function tickets(): BelongsToMany + public function products(): BelongsToMany { return $this - ->belongsToMany(Ticket::class, 'ticket_questions') - ->whereNull('ticket_questions.deleted_at'); + ->belongsToMany(Product::class, 'product_questions') + ->whereNull('product_questions.deleted_at'); } } diff --git a/backend/app/Models/QuestionAndAnswerView.php b/backend/app/Models/QuestionAndAnswerView.php index f35740ed2a..e5634138ff 100644 --- a/backend/app/Models/QuestionAndAnswerView.php +++ b/backend/app/Models/QuestionAndAnswerView.php @@ -3,6 +3,7 @@ namespace HiEvents\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * This model points to a view: question_and_answer_view @@ -14,4 +15,14 @@ class QuestionAndAnswerView extends Model protected $casts = [ 'answer' => 'array', ]; + + public function attendee(): BelongsTo + { + return $this->belongsTo(Attendee::class); + } + + public function question(): BelongsTo + { + return $this->belongsTo(Question::class); + } } diff --git a/backend/app/Models/QuestionAnswer.php b/backend/app/Models/QuestionAnswer.php index 3564d22ce5..c9c4de1313 100644 --- a/backend/app/Models/QuestionAnswer.php +++ b/backend/app/Models/QuestionAnswer.php @@ -17,7 +17,7 @@ protected function getFillableFields(): array { return [ QuestionAnswerDomainObjectAbstract::QUESTION_ID, - QuestionAnswerDomainObjectAbstract::TICKET_ID, + QuestionAnswerDomainObjectAbstract::PRODUCT_ID, QuestionAnswerDomainObjectAbstract::ORDER_ID, QuestionAnswerDomainObjectAbstract::ATTENDEE_ID, QuestionAnswerDomainObjectAbstract::ANSWER, diff --git a/backend/app/Models/TaxAndFee.php b/backend/app/Models/TaxAndFee.php index 32a656b9a5..1d36edbe80 100644 --- a/backend/app/Models/TaxAndFee.php +++ b/backend/app/Models/TaxAndFee.php @@ -8,9 +8,9 @@ class TaxAndFee extends BaseModel { protected $table = 'taxes_and_fees'; - public function tickets(): BelongsToMany + public function products(): BelongsToMany { - return $this->belongsToMany(Ticket::class, 'ticket_taxes_and_fees'); + return $this->belongsToMany(Product::class, 'product_taxes_and_fees'); } protected function getCastMap(): array diff --git a/backend/app/Models/Ticket.php b/backend/app/Models/Ticket.php deleted file mode 100644 index dea99092f3..0000000000 --- a/backend/app/Models/Ticket.php +++ /dev/null @@ -1,50 +0,0 @@ - 'float', - TicketDomainObjectAbstract::SALES_TAX_VOLUME => 'float', - ]; - } - - protected function getFillableFields(): array - { - return []; - } - - public function questions(): BelongsToMany - { - return $this->belongsToMany(Question::class, 'ticket_questions'); - } - - public function ticket_prices(): HasMany - { - return $this->hasMany(TicketPrice::class)->orderBy('order'); - } - - public function tax_and_fees(): BelongsToMany - { - return $this->belongsToMany(TaxAndFee::class, 'ticket_taxes_and_fees'); - } - - public function capacity_assignments(): BelongsToMany - { - return $this->belongsToMany(CapacityAssignment::class, 'ticket_capacity_assignments'); - } - - public function check_in_lists(): BelongsToMany - { - return $this->belongsToMany(CheckInList::class, 'ticket_check_in_lists'); - } -} diff --git a/backend/app/Models/Webhook.php b/backend/app/Models/Webhook.php new file mode 100644 index 0000000000..8948d0e8fb --- /dev/null +++ b/backend/app/Models/Webhook.php @@ -0,0 +1,36 @@ + 'array', + ]; + } + + public function webhook_logs(): HasMany + { + return $this->hasMany(WebhookLog::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function account(): BelongsTo + { + return $this->belongsTo(Account::class); + } +} diff --git a/backend/app/Models/WebhookLog.php b/backend/app/Models/WebhookLog.php new file mode 100644 index 0000000000..96aa8be7ea --- /dev/null +++ b/backend/app/Models/WebhookLog.php @@ -0,0 +1,13 @@ +belongsTo(Webhook::class); + } +} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 3499b364fb..008091b762 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; use Stripe\StripeClient; @@ -29,6 +30,11 @@ public function register(): void */ public function boot(): void { + if ($this->app->environment('local')) { + URL::forceScheme('https'); + URL::forceRootUrl(config('app.url')); + } + if (env('APP_DEBUG') === true && env('APP_LOG_QUERIES') === true && !app()->isProduction()) { DB::listen( static function ($query) { @@ -50,6 +56,10 @@ static function ($query) { private function bindDoctrineConnection(): void { + if ($this->app->environment('production')) { + return; + } + $this->app->bind( AbstractSchemaManager::class, function () { diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php index 2ae3adc901..dfa8c0dc73 100644 --- a/backend/app/Providers/RepositoryServiceProvider.php +++ b/backend/app/Providers/RepositoryServiceProvider.php @@ -4,6 +4,7 @@ namespace HiEvents\Providers; +use HiEvents\Repository\Eloquent\AccountConfigurationRepository; use HiEvents\Repository\Eloquent\AccountRepository; use HiEvents\Repository\Eloquent\AccountUserRepository; use HiEvents\Repository\Eloquent\AttendeeCheckInRepository; @@ -15,21 +16,28 @@ use HiEvents\Repository\Eloquent\EventSettingsRepository; use HiEvents\Repository\Eloquent\EventStatisticRepository; use HiEvents\Repository\Eloquent\ImageRepository; +use HiEvents\Repository\Eloquent\InvoiceRepository; use HiEvents\Repository\Eloquent\MessageRepository; +use HiEvents\Repository\Eloquent\OrderApplicationFeeRepository; use HiEvents\Repository\Eloquent\OrderItemRepository; +use HiEvents\Repository\Eloquent\OrderRefundRepository; use HiEvents\Repository\Eloquent\OrderRepository; use HiEvents\Repository\Eloquent\OrganizerRepository; use HiEvents\Repository\Eloquent\PasswordResetRepository; use HiEvents\Repository\Eloquent\PasswordResetTokenRepository; +use HiEvents\Repository\Eloquent\ProductCategoryRepository; +use HiEvents\Repository\Eloquent\ProductPriceRepository; +use HiEvents\Repository\Eloquent\ProductRepository; use HiEvents\Repository\Eloquent\PromoCodeRepository; use HiEvents\Repository\Eloquent\QuestionAnswerRepository; use HiEvents\Repository\Eloquent\QuestionRepository; use HiEvents\Repository\Eloquent\StripeCustomerRepository; use HiEvents\Repository\Eloquent\StripePaymentsRepository; use HiEvents\Repository\Eloquent\TaxAndFeeRepository; -use HiEvents\Repository\Eloquent\TicketPriceRepository; -use HiEvents\Repository\Eloquent\TicketRepository; use HiEvents\Repository\Eloquent\UserRepository; +use HiEvents\Repository\Eloquent\WebhookLogRepository; +use HiEvents\Repository\Eloquent\WebhookRepository; +use HiEvents\Repository\Interfaces\AccountConfigurationRepositoryInterface; use HiEvents\Repository\Interfaces\AccountRepositoryInterface; use HiEvents\Repository\Interfaces\AccountUserRepositoryInterface; use HiEvents\Repository\Interfaces\AttendeeCheckInRepositoryInterface; @@ -41,21 +49,27 @@ use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface; use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\ImageRepositoryInterface; +use HiEvents\Repository\Interfaces\InvoiceRepositoryInterface; use HiEvents\Repository\Interfaces\MessageRepositoryInterface; +use HiEvents\Repository\Interfaces\OrderApplicationFeeRepositoryInterface; use HiEvents\Repository\Interfaces\OrderItemRepositoryInterface; +use HiEvents\Repository\Interfaces\OrderRefundRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; use HiEvents\Repository\Interfaces\PasswordResetRepositoryInterface; use HiEvents\Repository\Interfaces\PasswordResetTokenRepositoryInterface; +use HiEvents\Repository\Interfaces\ProductCategoryRepositoryInterface; +use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface; +use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; use HiEvents\Repository\Interfaces\QuestionAnswerRepositoryInterface; use HiEvents\Repository\Interfaces\QuestionRepositoryInterface; use HiEvents\Repository\Interfaces\StripeCustomerRepositoryInterface; use HiEvents\Repository\Interfaces\StripePaymentsRepositoryInterface; use HiEvents\Repository\Interfaces\TaxAndFeeRepositoryInterface; -use HiEvents\Repository\Interfaces\TicketPriceRepositoryInterface; -use HiEvents\Repository\Interfaces\TicketRepositoryInterface; use HiEvents\Repository\Interfaces\UserRepositoryInterface; +use HiEvents\Repository\Interfaces\WebhookLogRepositoryInterface; +use HiEvents\Repository\Interfaces\WebhookRepositoryInterface; use Illuminate\Support\ServiceProvider; class RepositoryServiceProvider extends ServiceProvider @@ -67,7 +81,7 @@ class RepositoryServiceProvider extends ServiceProvider UserRepositoryInterface::class => UserRepository::class, AccountRepositoryInterface::class => AccountRepository::class, EventRepositoryInterface::class => EventRepository::class, - TicketRepositoryInterface::class => TicketRepository::class, + ProductRepositoryInterface::class => ProductRepository::class, OrderRepositoryInterface::class => OrderRepository::class, AttendeeRepositoryInterface::class => AttendeeRepository::class, OrderItemRepositoryInterface::class => OrderItemRepository::class, @@ -80,7 +94,7 @@ class RepositoryServiceProvider extends ServiceProvider PasswordResetRepositoryInterface::class => PasswordResetRepository::class, TaxAndFeeRepositoryInterface::class => TaxAndFeeRepository::class, ImageRepositoryInterface::class => ImageRepository::class, - TicketPriceRepositoryInterface::class => TicketPriceRepository::class, + ProductPriceRepositoryInterface::class => ProductPriceRepository::class, EventStatisticRepositoryInterface::class => EventStatisticRepository::class, EventDailyStatisticRepositoryInterface::class => EventDailyStatisticRepository::class, EventSettingsRepositoryInterface::class => EventSettingsRepository::class, @@ -90,6 +104,13 @@ class RepositoryServiceProvider extends ServiceProvider StripeCustomerRepositoryInterface::class => StripeCustomerRepository::class, CheckInListRepositoryInterface::class => CheckInListRepository::class, AttendeeCheckInRepositoryInterface::class => AttendeeCheckInRepository::class, + ProductCategoryRepositoryInterface::class => ProductCategoryRepository::class, + InvoiceRepositoryInterface::class => InvoiceRepository::class, + OrderRefundRepositoryInterface::class => OrderRefundRepository::class, + WebhookRepositoryInterface::class => WebhookRepository::class, + WebhookLogRepositoryInterface::class => WebhookLogRepository::class, + OrderApplicationFeeRepositoryInterface::class => OrderApplicationFeeRepository::class, + AccountConfigurationRepositoryInterface::class => AccountConfigurationRepository::class, ]; public function register(): void diff --git a/backend/app/Repository/Eloquent/AccountConfigurationRepository.php b/backend/app/Repository/Eloquent/AccountConfigurationRepository.php new file mode 100644 index 0000000000..5b10131f88 --- /dev/null +++ b/backend/app/Repository/Eloquent/AccountConfigurationRepository.php @@ -0,0 +1,20 @@ +model = $this->model->select('attendees.*') ->join('orders', 'orders.id', '=', 'attendees.order_id') - ->whereIn('orders.status', [OrderStatus::COMPLETED->name, OrderStatus::CANCELLED->name]) + ->whereIn('orders.status', [OrderStatus::COMPLETED->name, OrderStatus::CANCELLED->name, OrderStatus::AWAITING_OFFLINE_PAYMENT->name]) ->orderBy( 'attendees.' . ($params->sort_by ?? AttendeeDomainObject::getDefaultSort()), $params->sort_direction ?? 'desc', @@ -108,11 +108,11 @@ public function getAttendeesByCheckInShortId(string $shortId, QueryParamsDTO $pa $this->model = $this->model->select('attendees.*') ->join('orders', 'orders.id', '=', 'attendees.order_id') - ->join('ticket_check_in_lists', 'ticket_check_in_lists.ticket_id', '=', 'attendees.ticket_id') - ->join('check_in_lists', 'check_in_lists.id', '=', 'ticket_check_in_lists.check_in_list_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) - ->where('attendees.status', AttendeeStatus::ACTIVE->name) - ->whereIn('orders.status', [OrderStatus::COMPLETED->name]); + ->whereIn('attendees.status',[AttendeeStatus::ACTIVE->name, AttendeeStatus::CANCELLED->name, AttendeeStatus::AWAITING_PAYMENT->name]) + ->whereIn('orders.status', [OrderStatus::COMPLETED->name, OrderStatus::AWAITING_OFFLINE_PAYMENT->name]); $this->loadRelation(new Relationship(AttendeeCheckInDomainObject::class, name: 'check_in')); diff --git a/backend/app/Repository/Eloquent/BaseRepository.php b/backend/app/Repository/Eloquent/BaseRepository.php index c781a44bf3..b5f0e1a20e 100644 --- a/backend/app/Repository/Eloquent/BaseRepository.php +++ b/backend/app/Repository/Eloquent/BaseRepository.php @@ -15,6 +15,7 @@ use Illuminate\Database\DatabaseManager; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Foundation\Application; use Illuminate\Pagination\LengthAwarePaginator; @@ -33,6 +34,8 @@ abstract class BaseRepository implements RepositoryInterface protected DatabaseManager $db; + protected int $maxPerPage = self::MAX_PAGINATE_LIMIT; + /** @var Relationship[] */ protected array $eagerLoads = []; @@ -50,6 +53,13 @@ public function __construct(Application $application, DatabaseManager $db) */ abstract protected function getModel(): string; + public function setMaxPerPage(int $maxPerPage): static + { + $this->maxPerPage = $maxPerPage; + + return $this; + } + public function all(array $columns = self::DEFAULT_COLUMNS): Collection { return $this->handleResults($this->model->all($columns)); @@ -106,6 +116,9 @@ public function paginateEloquentRelation( return $this->handleResults($relation->paginate($this->getPaginationPerPage($limit), $columns)); } + /** + * @throws ModelNotFoundException + */ public function findById(int $id, array $columns = self::DEFAULT_COLUMNS): DomainObjectInterface { return $this->handleSingleResult($this->model->findOrFail($id, $columns)); @@ -128,10 +141,25 @@ public function findFirst(int $id, array $columns = self::DEFAULT_COLUMNS): ?Dom return $this->handleSingleResult($this->model->findOrFail($id, $columns)); } - public function findWhere(array $where, array $columns = self::DEFAULT_COLUMNS): Collection + public function findWhere( + array $where, + array $columns = self::DEFAULT_COLUMNS, + array $orderAndDirections = [], + ): Collection { $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(); return $this->handleResults($model); @@ -283,6 +311,13 @@ public function loadRelation(string|Relationship $relationship): static return $this; } + public function includeDeleted(): static + { + $this->model = $this->model->withTrashed(); + + return $this; + } + protected function applyConditions(array $where): void { foreach ($where as $field => $value) { @@ -352,17 +387,31 @@ protected function applyFilterFields(QueryParamsDTO $params, array $allowedFilte 'gt' => '>', 'gte' => '>=', 'like' => 'LIKE', + 'in' => 'IN', ]; $operator = $operatorMapping[$filterField->operator] ?? throw new BadMethodCallException( sprintf('Operator %s is not supported', $filterField->operator) ); - $this->model = $this->model->where( - column: $filterField->field, - operator: $operator, - value: $isNull ? null : $filterField->value, - ); + // Special handling for IN operator + if ($operator === 'IN') { + // Ensure value is array or convert comma-separated string to array + $value = is_array($filterField->value) + ? $filterField->value + : explode(',', $filterField->value); + + $this->model = $this->model->whereIn( + column: $filterField->field, + values: $value + ); + } else { + $this->model = $this->model->where( + column: $filterField->field, + operator: $operator, + value: $isNull ? null : $filterField->value, + ); + } }); } } @@ -379,7 +428,7 @@ private function getPaginationPerPage(?int $perPage): int $perPage = self::DEFAULT_PAGINATE_LIMIT; } - return (int)min($perPage, self::MAX_PAGINATE_LIMIT); + return (int)min($perPage, $this->maxPerPage); } /** @@ -413,7 +462,7 @@ private function hydrateDomainObjectFromModel( } /** - * This method will handle nested eager loading of relationships. It works, but it's not pretty. + * This method will handle nested eager loading of relationships * * @param Model $model * @param DomainObjectInterface $object diff --git a/backend/app/Repository/Eloquent/CheckInListRepository.php b/backend/app/Repository/Eloquent/CheckInListRepository.php index e539b130b8..e3259a110f 100644 --- a/backend/app/Repository/Eloquent/CheckInListRepository.php +++ b/backend/app/Repository/Eloquent/CheckInListRepository.php @@ -38,10 +38,10 @@ public function getCheckedInAttendeeCountById(int $checkInListId): CheckedInAtte valid_attendees AS ( SELECT a.id, tcil.check_in_list_id FROM attendees a - JOIN ticket_check_in_lists tcil ON a.ticket_id = tcil.ticket_id + JOIN product_check_in_lists tcil ON a.product_id = tcil.product_id WHERE a.deleted_at IS NULL AND tcil.deleted_at IS NULL - AND a.status = 'ACTIVE' + AND a.status in ('ACTIVE', 'AWAITING_PAYMENT') ) SELECT cil.id AS check_in_list_id, @@ -79,7 +79,7 @@ public function getCheckedInAttendeeCountByIds(array $checkInListIds): Collectio valid_attendees AS ( SELECT a.id, tcil.check_in_list_id FROM attendees a - JOIN ticket_check_in_lists tcil ON a.ticket_id = tcil.ticket_id + JOIN product_check_in_lists tcil ON a.product_id = tcil.product_id WHERE a.deleted_at IS NULL AND tcil.deleted_at IS NULL AND a.status = '$attendeeActiveStatus' diff --git a/backend/app/Repository/Eloquent/InvoiceRepository.php b/backend/app/Repository/Eloquent/InvoiceRepository.php new file mode 100644 index 0000000000..b7a85edb47 --- /dev/null +++ b/backend/app/Repository/Eloquent/InvoiceRepository.php @@ -0,0 +1,42 @@ +model + ->whereHas('order', function ($query) use ($eventId) { + $query->where('event_id', $eventId); + }) + ->orderBy('id', 'desc') + ->first(); + + return $this->handleSingleResult($invoice); + } + + public function findLatestInvoiceForOrder(int $orderId): ?InvoiceDomainObject + { + $invoice = $this->model + ->where('order_id', $orderId) + ->orderBy('id', 'desc') + ->first(); + + return $this->handleSingleResult($invoice); + } +} diff --git a/backend/app/Repository/Eloquent/OrderApplicationFeeRepository.php b/backend/app/Repository/Eloquent/OrderApplicationFeeRepository.php new file mode 100644 index 0000000000..a3aab12e15 --- /dev/null +++ b/backend/app/Repository/Eloquent/OrderApplicationFeeRepository.php @@ -0,0 +1,20 @@ +filter_fields)) { + $this->applyFilterFields($params, OrderDomainObject::getAllowedFilterFields()); + } + $this->model = $this->model->orderBy( $params->sort_by ?? OrderDomainObject::getDefaultSort(), $params->sort_direction ?? 'desc', @@ -96,4 +101,17 @@ 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() + ); + } } diff --git a/backend/app/Repository/Eloquent/ProductCategoryRepository.php b/backend/app/Repository/Eloquent/ProductCategoryRepository.php new file mode 100644 index 0000000000..6a1546c471 --- /dev/null +++ b/backend/app/Repository/Eloquent/ProductCategoryRepository.php @@ -0,0 +1,50 @@ +model + ->where('event_id', $eventId) + ->with(['products']); + + // Apply filters from QueryParamsDTO, if needed + if (!empty($queryParamsDTO->filter_fields)) { + foreach ($queryParamsDTO->filter_fields as $filter) { + $query->where($filter->field, $filter->operator ?? '=', $filter->value); + } + } + + // Apply sorting from QueryParamsDTO + if (!empty($queryParamsDTO->sort_by)) { + $query->orderBy($queryParamsDTO->sort_by, $queryParamsDTO->sort_direction ?? 'asc'); + } + + return $query->get(); + } + + public function getNextOrder(int $eventId) + { + return $this->model + ->where('event_id', $eventId) + ->max('order') + 1; + } +} diff --git a/backend/app/Repository/Eloquent/ProductPriceRepository.php b/backend/app/Repository/Eloquent/ProductPriceRepository.php new file mode 100644 index 0000000000..7114d6f3bc --- /dev/null +++ b/backend/app/Repository/Eloquent/ProductPriceRepository.php @@ -0,0 +1,20 @@ +query)) { + $where[] = static function (Builder $builder) use ($params) { + $builder + ->where(ProductDomainObjectAbstract::TITLE, 'ilike', '%' . $params->query . '%'); + }; + } + + $this->model = $this->model->orderBy( + $params->sort_by ?? ProductDomainObject::getDefaultSort(), + $params->sort_direction ?? ProductDomainObject::getDefaultSortDirection(), + ); + + return $this->paginateWhere( + where: $where, + limit: $params->per_page, + page: $params->page, + ); + } + + /** + * @param int $productId + * @param int $productPriceId + * @return int + */ + public function getQuantityRemainingForProductPrice(int $productId, int $productPriceId): int + { + $query = <<db->selectOne($query, [ + 'productPriceId' => $productPriceId, + 'productId' => $productId + ]); + + if ($result === null) { + throw new RuntimeException('Product price not found'); + } + + if ($result->unlimited_products_available) { + return Constants::INFINITE; + } + + return (int)$result->quantity_remaining; + } + + public function getTaxesByProductId(int $productId): Collection + { + $query = <<db->select($query, [ + 'productId' => $productId + ]); + + return $this->handleResults($taxAndFees, TaxAndFeesDomainObject::class); + } + + public function getProductsByTaxId(int $taxId): Collection + { + $query = <<model->select($query, [ + 'taxAndFeeId' => $taxId + ]); + + return $this->handleResults($products, ProductDomainObject::class); + } + + public function getCapacityAssignmentsByProductId(int $productId): Collection + { + $capacityAssignments = CapacityAssignment::whereHas('products', static function ($query) use ($productId) { + $query->where('product_id', $productId); + })->get(); + + return $this->handleResults($capacityAssignments, CapacityAssignmentDomainObject::class); + } + + public function addTaxesAndFeesToProduct(int $productId, array $taxIds): void + { + Product::findOrFail($productId)?->tax_and_fees()->sync($taxIds); + } + + public function addCapacityAssignmentToProducts(int $capacityAssignmentId, array $productIds): void + { + $productIds = array_unique($productIds); + + Product::whereNotIn('id', $productIds) + ->whereHas('capacity_assignments', function ($query) use ($capacityAssignmentId) { + $query->where('capacity_assignment_id', $capacityAssignmentId); + }) + ->each(function (Product $product) use ($capacityAssignmentId) { + $product->capacity_assignments()->detach($capacityAssignmentId); + }); + + Product::whereIn('id', $productIds) + ->each(function (Product $product) use ($capacityAssignmentId) { + $product->capacity_assignments()->syncWithoutDetaching([$capacityAssignmentId]); + }); + } + + public function addCheckInListToProducts(int $checkInListId, array $productIds): void + { + $productIds = array_unique($productIds); + + Product::whereNotIn('id', $productIds) + ->whereHas('check_in_lists', function ($query) use ($checkInListId) { + $query->where('check_in_list_id', $checkInListId); + }) + ->each(function (Product $product) use ($checkInListId) { + $product->check_in_lists()->detach($checkInListId); + }); + + Product::whereIn('id', $productIds) + ->each(function (Product $product) use ($checkInListId) { + $product->check_in_lists()->syncWithoutDetaching([$checkInListId]); + }); + } + + public function removeCheckInListFromProducts(int $checkInListId): void + { + $checkInList = CheckInList::find($checkInListId); + + $checkInList?->products()->detach(); + } + + public function removeCapacityAssignmentFromProducts(int $capacityAssignmentId): void + { + $capacityAssignment = CapacityAssignment::find($capacityAssignmentId); + + $capacityAssignment?->products()->detach(); + } + + /** + * @throws Throwable + */ + public function bulkUpdateProductsAndCategories(int $eventId, array $productUpdates, array $categoryUpdates): void + { + $this->db->beginTransaction(); + + try { + $productIds = array_column($productUpdates, 'id'); + $productOrders = range(1, count($productUpdates)); + $productCategoryIds = array_column($productUpdates, 'product_category_id'); + + $productParameters = [ + 'eventId' => $eventId, + 'productIds' => '{' . implode(',', $productIds) . '}', + 'productOrders' => '{' . implode(',', $productOrders) . '}', + 'productCategoryIds' => '{' . implode(',', $productCategoryIds) . '}', + ]; + + $productUpdateQuery = "WITH new_order AS ( + SELECT unnest(:productIds::bigint[]) AS product_id, + unnest(:productOrders::int[]) AS order, + unnest(:productCategoryIds::bigint[]) AS category_id + ) + UPDATE products + SET \"order\" = new_order.order, + product_category_id = new_order.category_id, + updated_at = NOW() + FROM new_order + WHERE products.id = new_order.product_id AND products.event_id = :eventId"; + + $this->db->update($productUpdateQuery, $productParameters); + + $categoryIds = array_column($categoryUpdates, 'id'); + $categoryOrders = array_column($categoryUpdates, 'order'); + + $categoryParameters = [ + 'eventId' => $eventId, + 'categoryIds' => '{' . implode(',', $categoryIds) . '}', + 'categoryOrders' => '{' . implode(',', $categoryOrders) . '}', + ]; + + $categoryUpdateQuery = "WITH new_category_order AS ( + SELECT unnest(:categoryIds::bigint[]) AS category_id, + unnest(:categoryOrders::int[]) AS order + ) + UPDATE product_categories + SET \"order\" = new_category_order.order, + updated_at = NOW() + FROM new_category_order + WHERE product_categories.id = new_category_order.category_id AND product_categories.event_id = :eventId"; + + $this->db->update($categoryUpdateQuery, $categoryParameters); + + $this->db->commit(); + } catch (Exception $e) { + $this->db->rollBack(); + throw $e; + } + } + + public function hasAssociatedOrders(int $productId): bool + { + return $this->db->table('order_items') + ->join('orders', 'order_items.order_id', '=', 'orders.id') + ->whereIn('orders.status', [OrderStatus::COMPLETED->name, OrderStatus::CANCELLED->name]) + ->where('order_items.product_id', $productId) + ->exists(); + } + + public function getModel(): string + { + return Product::class; + } + + public function getDomainObject(): string + { + return ProductDomainObject::class; + } +} diff --git a/backend/app/Repository/Eloquent/QuestionRepository.php b/backend/app/Repository/Eloquent/QuestionRepository.php index 8c2e1e9a0d..1e0dcdbb60 100644 --- a/backend/app/Repository/Eloquent/QuestionRepository.php +++ b/backend/app/Repository/Eloquent/QuestionRepository.php @@ -5,21 +5,21 @@ use HiEvents\DomainObjects\Generated\QuestionDomainObjectAbstract; use HiEvents\DomainObjects\QuestionDomainObject; use HiEvents\Models\Question; -use HiEvents\Models\TicketQuestion; +use HiEvents\Models\ProductQuestion; use HiEvents\Repository\Interfaces\QuestionRepositoryInterface; -use HiEvents\Repository\Interfaces\TicketRepositoryInterface; +use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use Illuminate\Database\DatabaseManager; use Illuminate\Foundation\Application; use Illuminate\Support\Collection; class QuestionRepository extends BaseRepository implements QuestionRepositoryInterface { - private TicketRepositoryInterface $ticketRepository; + private ProductRepositoryInterface $productRepository; - public function __construct(Application $application, DatabaseManager $db, TicketRepositoryInterface $ticketRepository) + public function __construct(Application $application, DatabaseManager $db, ProductRepositoryInterface $productRepository) { parent::__construct($application, $db); - $this->ticketRepository = $ticketRepository; + $this->productRepository = $productRepository; } protected function getModel(): string @@ -32,37 +32,37 @@ public function getDomainObject(): string return QuestionDomainObject::class; } - public function create(array $attributes, array $ticketIds = []): QuestionDomainObject + public function create(array $attributes, array $productIds = []): QuestionDomainObject { /** @var QuestionDomainObject $question */ $question = parent::create($attributes); - foreach ($ticketIds as $ticketId) { - $ticketQuestion = new TicketQuestion(); - $ticketQuestion->create([ - 'ticket_id' => $ticketId, + foreach ($productIds as $productId) { + $productQuestion = new ProductQuestion(); + $productQuestion->create([ + 'product_id' => $productId, 'question_id' => $question->getId(), ]); } - $question->setTickets($this->ticketRepository->findWhereIn('id', $ticketIds)); + $question->setProducts($this->productRepository->findWhereIn('id', $productIds)); return $question; } - public function updateQuestion(int $questionId, int $eventId, array $attributes, array $ticketIds = []): void + public function updateQuestion(int $questionId, int $eventId, array $attributes, array $productIds = []): void { $this->updateWhere($attributes, [ 'id' => $questionId, 'event_id' => $eventId, ]); - TicketQuestion::where('question_id', $questionId)->delete(); + ProductQuestion::where('question_id', $questionId)->delete(); - foreach ($ticketIds as $ticketId) { - $ticketQuestion = new TicketQuestion(); - $ticketQuestion->create([ - 'ticket_id' => $ticketId, + foreach ($productIds as $productId) { + $productQuestion = new ProductQuestion(); + $productQuestion->create([ + 'product_id' => $productId, 'question_id' => $questionId, ]); } diff --git a/backend/app/Repository/Eloquent/TicketPriceRepository.php b/backend/app/Repository/Eloquent/TicketPriceRepository.php deleted file mode 100644 index 9f40580138..0000000000 --- a/backend/app/Repository/Eloquent/TicketPriceRepository.php +++ /dev/null @@ -1,20 +0,0 @@ -query)) { - $where[] = static function (Builder $builder) use ($params) { - $builder - ->where(TicketDomainObjectAbstract::TITLE, 'ilike', '%' . $params->query . '%'); - }; - } - - $this->model = $this->model->orderBy( - $params->sort_by ?? TicketDomainObject::getDefaultSort(), - $params->sort_direction ?? TicketDomainObject::getDefaultSortDirection(), - ); - - return $this->paginateWhere( - where: $where, - limit: $params->per_page, - page: $params->page, - ); - } - - /** - * @param int $ticketId - * @param int $ticketPriceId - * @return int - */ - public function getQuantityRemainingForTicketPrice(int $ticketId, int $ticketPriceId): int - { - $query = <<db->selectOne($query, [ - 'ticketPriceId' => $ticketPriceId, - 'ticketId' => $ticketId - ]); - - if ($result === null) { - throw new RuntimeException('Ticket price not found'); - } - - if ($result->unlimited_tickets_available) { - return Constants::INFINITE; - } - - return (int)$result->quantity_remaining; - } - - public function getTaxesByTicketId(int $ticketId): Collection - { - $query = <<db->select($query, [ - 'ticketId' => $ticketId - ]); - - return $this->handleResults($taxAndFees, TaxAndFeesDomainObject::class); - } - - public function getTicketsByTaxId(int $taxId): Collection - { - $query = <<model->select($query, [ - 'taxAndFeeId' => $taxId - ]); - - return $this->handleResults($tickets, TicketDomainObject::class); - } - - public function getCapacityAssignmentsByTicketId(int $ticketId): Collection - { - $capacityAssignments = CapacityAssignment::whereHas('tickets', static function ($query) use ($ticketId) { - $query->where('ticket_id', $ticketId); - })->get(); - - return $this->handleResults($capacityAssignments, CapacityAssignmentDomainObject::class); - } - - public function addTaxesAndFeesToTicket(int $ticketId, array $taxIds): void - { - Ticket::findOrFail($ticketId)?->tax_and_fees()->sync($taxIds); - } - - public function addCapacityAssignmentToTickets(int $capacityAssignmentId, array $ticketIds): void - { - $ticketIds = array_unique($ticketIds); - - Ticket::whereNotIn('id', $ticketIds) - ->whereHas('capacity_assignments', function ($query) use ($capacityAssignmentId) { - $query->where('capacity_assignment_id', $capacityAssignmentId); - }) - ->each(function (Ticket $ticket) use ($capacityAssignmentId) { - $ticket->capacity_assignments()->detach($capacityAssignmentId); - }); - - Ticket::whereIn('id', $ticketIds) - ->each(function (Ticket $ticket) use ($capacityAssignmentId) { - $ticket->capacity_assignments()->syncWithoutDetaching([$capacityAssignmentId]); - }); - } - - public function addCheckInListToTickets(int $checkInListId, array $ticketIds): void - { - $ticketIds = array_unique($ticketIds); - - Ticket::whereNotIn('id', $ticketIds) - ->whereHas('check_in_lists', function ($query) use ($checkInListId) { - $query->where('check_in_list_id', $checkInListId); - }) - ->each(function (Ticket $ticket) use ($checkInListId) { - $ticket->check_in_lists()->detach($checkInListId); - }); - - Ticket::whereIn('id', $ticketIds) - ->each(function (Ticket $ticket) use ($checkInListId) { - $ticket->check_in_lists()->syncWithoutDetaching([$checkInListId]); - }); - } - - public function removeCheckInListFromTickets(int $checkInListId): void - { - $checkInList = CheckInList::find($checkInListId); - - $checkInList?->tickets()->detach(); - } - - public function removeCapacityAssignmentFromTickets(int $capacityAssignmentId): void - { - $capacityAssignment = CapacityAssignment::find($capacityAssignmentId); - - $capacityAssignment?->tickets()->detach(); - } - - public function sortTickets(int $eventId, array $orderedTicketIds): void - { - $parameters = [ - 'eventId' => $eventId, - 'ticketIds' => '{' . implode(',', $orderedTicketIds) . '}', - 'orders' => '{' . implode(',', range(1, count($orderedTicketIds))) . '}', - ]; - - $query = "WITH new_order AS ( - SELECT unnest(:ticketIds::bigint[]) AS ticket_id, - unnest(:orders::int[]) AS order - ) - UPDATE tickets - SET \"order\" = new_order.order - FROM new_order - WHERE tickets.id = new_order.ticket_id AND tickets.event_id = :eventId"; - - $this->db->update($query, $parameters); - } - - public function getModel(): string - { - return Ticket::class; - } - - public function getDomainObject(): string - { - return TicketDomainObject::class; - } -} diff --git a/backend/app/Repository/Eloquent/Value/OrderAndDirection.php b/backend/app/Repository/Eloquent/Value/OrderAndDirection.php new file mode 100644 index 0000000000..4a6058efaa --- /dev/null +++ b/backend/app/Repository/Eloquent/Value/OrderAndDirection.php @@ -0,0 +1,36 @@ +validate(); + } + + public function getOrder(): string + { + return $this->order; + } + + public function getDirection(): string + { + return $this->direction; + } + + private function validate(): void + { + if (!in_array($this->direction, ['asc', 'desc'])) { + throw new InvalidArgumentException(__('Invalid direction. Must be either asc or desc')); + } + } +} diff --git a/backend/app/Repository/Eloquent/Value/Relationship.php b/backend/app/Repository/Eloquent/Value/Relationship.php index 857173cae3..6dfcb7613c 100644 --- a/backend/app/Repository/Eloquent/Value/Relationship.php +++ b/backend/app/Repository/Eloquent/Value/Relationship.php @@ -2,18 +2,27 @@ namespace HiEvents\Repository\Eloquent\Value; -readonly class Relationship +use HiEvents\DomainObjects\Interfaces\DomainObjectInterface; +use InvalidArgumentException; + +class Relationship { public function __construct( - private string $domainObject, + private readonly string $domainObject, /** * @var Relationship[]|null */ - private ?array $nested = [], + private readonly ?array $nested = [], + + private readonly ?string $name = null, - private ?string $name = null, + /** + * @var OrderAndDirection[] + */ + private readonly array $orderAndDirections = [], ) { + $this->validate(); } public function getName(): string @@ -31,13 +40,23 @@ public function getDomainObject(): string return $this->domainObject; } + public function getOrderAndDirections(): array + { + return $this->orderAndDirections; + } + public function buildLaravelEagerLoadArray(): array { - if (!$this->nested) { - return [$this->getName()]; + $results = [ + $this->getName() => $this->buildOrderAndDirectionEloquentCallback() + ]; + + // If there are nested relationships, build them and merge into the results array + if ($this->nested) { + $results = array_merge($results, $this->buildNested($this, '')); } - return $this->buildNested($this, ''); + return $results; } private function buildNested(Relationship $relationship, string $prefix): array @@ -47,11 +66,51 @@ private function buildNested(Relationship $relationship, string $prefix): array if ($relationship->nested) { foreach ($relationship->nested as $nested) { $nestedPrefix = $prefix === '' ? $relationship->getName() : $prefix . '.' . $relationship->getName(); - $results[] = $nestedPrefix . '.' . $nested->getName(); + $results[$nestedPrefix . '.' . $nested->getName()] = $nested->buildOrderAndDirectionEloquentCallback(); $results = array_merge($results, $this->buildNested($nested, $nestedPrefix)); } } return $results; } + + private function buildOrderAndDirectionEloquentCallback(): callable|array + { + if ($this->getOrderAndDirections() === []) { + return []; + } + + return function ($query) { + foreach ($this->orderAndDirections as $orderAndDirection) { + $query->orderBy($orderAndDirection->getOrder(), $orderAndDirection->getDirection()); + } + }; + } + + private function validate(): void + { + if (!is_subclass_of($this->domainObject, DomainObjectInterface::class)) { + throw new InvalidArgumentException( + __('DomainObject must be a valid :interface.', [ + 'interface' => DomainObjectInterface::class, + ]), + ); + } + + foreach ($this->nested as $nested) { + if (!is_a($nested, __CLASS__)) { + throw new InvalidArgumentException( + __('Nested relationships must be an array of Relationship objects.'), + ); + } + } + + foreach ($this->orderAndDirections as $orderAndDirection) { + if (!is_a($orderAndDirection, OrderAndDirection::class)) { + throw new InvalidArgumentException( + __('OrderAndDirections must be an array of OrderAndDirection objects.'), + ); + } + } + } } diff --git a/backend/app/Repository/Eloquent/WebhookLogRepository.php b/backend/app/Repository/Eloquent/WebhookLogRepository.php new file mode 100644 index 0000000000..1c518dce10 --- /dev/null +++ b/backend/app/Repository/Eloquent/WebhookLogRepository.php @@ -0,0 +1,37 @@ +model->where('webhook_id', $webhookId); + + $totalLogs = $query->count(); + + if ($totalLogs > $numberToKeep) { + $query->orderBy('created_at', 'desc') + ->skip($numberToKeep) + ->take($totalLogs - $numberToKeep) + ->forceDelete(); + } + } +} diff --git a/backend/app/Repository/Eloquent/WebhookRepository.php b/backend/app/Repository/Eloquent/WebhookRepository.php new file mode 100644 index 0000000000..bf422e40aa --- /dev/null +++ b/backend/app/Repository/Eloquent/WebhookRepository.php @@ -0,0 +1,20 @@ + + */ +interface AccountConfigurationRepositoryInterface extends RepositoryInterface +{ + +} diff --git a/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php b/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php index fba38d34c0..ea8dfd99ca 100644 --- a/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php @@ -12,7 +12,7 @@ /** * @extends BaseRepository */ -interface AttendeeRepositoryInterface extends RepositoryInterFace +interface AttendeeRepositoryInterface extends RepositoryInterface { public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator; diff --git a/backend/app/Repository/Interfaces/InvoiceRepositoryInterface.php b/backend/app/Repository/Interfaces/InvoiceRepositoryInterface.php new file mode 100644 index 0000000000..e2091a9fb9 --- /dev/null +++ b/backend/app/Repository/Interfaces/InvoiceRepositoryInterface.php @@ -0,0 +1,16 @@ + + */ +interface InvoiceRepositoryInterface extends RepositoryInterface +{ + public function findLatestInvoiceForEvent(int $eventId): ?InvoiceDomainObject; + + public function findLatestInvoiceForOrder(int $orderId): ?InvoiceDomainObject; +} diff --git a/backend/app/Repository/Interfaces/OrderApplicationFeeRepositoryInterface.php b/backend/app/Repository/Interfaces/OrderApplicationFeeRepositoryInterface.php new file mode 100644 index 0000000000..7f9f517d73 --- /dev/null +++ b/backend/app/Repository/Interfaces/OrderApplicationFeeRepositoryInterface.php @@ -0,0 +1,14 @@ + + */ +interface OrderApplicationFeeRepositoryInterface extends RepositoryInterface +{ + +} diff --git a/backend/app/Repository/Interfaces/OrderRefundRepositoryInterface.php b/backend/app/Repository/Interfaces/OrderRefundRepositoryInterface.php new file mode 100644 index 0000000000..0c475af43e --- /dev/null +++ b/backend/app/Repository/Interfaces/OrderRefundRepositoryInterface.php @@ -0,0 +1,13 @@ + + */ +interface OrderRefundRepositoryInterface extends RepositoryInterface +{ + +} diff --git a/backend/app/Repository/Interfaces/OrderRepositoryInterface.php b/backend/app/Repository/Interfaces/OrderRepositoryInterface.php index d7abbbdba0..1c350503b7 100644 --- a/backend/app/Repository/Interfaces/OrderRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/OrderRepositoryInterface.php @@ -9,6 +9,7 @@ use HiEvents\Http\DTO\QueryParamsDTO; use HiEvents\Repository\Eloquent\BaseRepository; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; /** * @extends BaseRepository @@ -24,4 +25,6 @@ public function getAttendees(int $orderId); public function addOrderItem(array $data): OrderItemDomainObject; public function findByShortId(string $orderShortId): ?OrderDomainObject; + + public function findOrdersAssociatedWithProducts(int $eventId, array $productIds, array $orderStatuses): Collection; } diff --git a/backend/app/Repository/Interfaces/ProductCategoryRepositoryInterface.php b/backend/app/Repository/Interfaces/ProductCategoryRepositoryInterface.php new file mode 100644 index 0000000000..593fb5d7bf --- /dev/null +++ b/backend/app/Repository/Interfaces/ProductCategoryRepositoryInterface.php @@ -0,0 +1,18 @@ + + */ +interface ProductCategoryRepositoryInterface extends RepositoryInterface +{ + public function findByEventId(int $eventId, QueryParamsDTO $queryParamsDTO): Collection; + + public function getNextOrder(int $eventId); +} diff --git a/backend/app/Repository/Interfaces/ProductPriceRepositoryInterface.php b/backend/app/Repository/Interfaces/ProductPriceRepositoryInterface.php new file mode 100644 index 0000000000..23b538f1e4 --- /dev/null +++ b/backend/app/Repository/Interfaces/ProductPriceRepositoryInterface.php @@ -0,0 +1,15 @@ + + */ +interface ProductPriceRepositoryInterface extends RepositoryInterface +{ +} diff --git a/backend/app/Repository/Interfaces/ProductRepositoryInterface.php b/backend/app/Repository/Interfaces/ProductRepositoryInterface.php new file mode 100644 index 0000000000..d484fb0c89 --- /dev/null +++ b/backend/app/Repository/Interfaces/ProductRepositoryInterface.php @@ -0,0 +1,93 @@ + + */ +interface ProductRepositoryInterface extends RepositoryInterface +{ + /** + * @param int $eventId + * @param QueryParamsDTO $params + * @return LengthAwarePaginator + */ + public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator; + + /** + * @param int $productId + * @param int $productPriceId + * @return int + */ + public function getQuantityRemainingForProductPrice(int $productId, int $productPriceId): int; + + /** + * @param int $productId + * @return Collection + */ + public function getTaxesByProductId(int $productId): Collection; + + /** + * @param int $taxId + * @return Collection + */ + public function getProductsByTaxId(int $taxId): Collection; + + /** + * @param int $productId + * @return Collection + */ + public function getCapacityAssignmentsByProductId(int $productId): Collection; + + /** + * @param int $productId + * @param array $taxIds + * @return void + */ + public function addTaxesAndFeesToProduct(int $productId, array $taxIds): void; + + /** + * @param array $productIds + * @param int $capacityAssignmentId + * @return void + */ + public function addCapacityAssignmentToProducts(int $capacityAssignmentId, array $productIds): void; + + /** + * @param int $checkInListId + * @param array $productIds + * @return void + */ + public function addCheckInListToProducts(int $checkInListId, array $productIds): void; + + /** + * @param int $checkInListId + * @return void + */ + public function removeCheckInListFromProducts(int $checkInListId): void; + + /** + * @param int $capacityAssignmentId + * @return void + */ + public function removeCapacityAssignmentFromProducts(int $capacityAssignmentId): void; + + + /** + * @param int $eventId + * @param array $productUpdates + * @param array $categoryUpdates + * @return void + */ + public function bulkUpdateProductsAndCategories(int $eventId, array $productUpdates, array $categoryUpdates): void; + + public function hasAssociatedOrders(int $productId): bool; +} diff --git a/backend/app/Repository/Interfaces/QuestionRepositoryInterface.php b/backend/app/Repository/Interfaces/QuestionRepositoryInterface.php index 6a5438de12..61c303e64e 100644 --- a/backend/app/Repository/Interfaces/QuestionRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/QuestionRepositoryInterface.php @@ -13,9 +13,9 @@ interface QuestionRepositoryInterface extends RepositoryInterface { public function findByEventId(int $eventId): Collection; - public function create(array $attributes, array $ticketIds = []): QuestionDomainObject; + public function create(array $attributes, array $productIds = []): QuestionDomainObject; - public function updateQuestion(int $questionId, int $eventId, array $attributes, array $ticketIds = []): void; + public function updateQuestion(int $questionId, int $eventId, array $attributes, array $productIds = []): void; public function sortQuestions(int $eventId, array $orderedQuestionIds): void; } diff --git a/backend/app/Repository/Interfaces/RepositoryInterface.php b/backend/app/Repository/Interfaces/RepositoryInterface.php index 5481dbc8b8..0426e15288 100644 --- a/backend/app/Repository/Interfaces/RepositoryInterface.php +++ b/backend/app/Repository/Interfaces/RepositoryInterface.php @@ -4,6 +4,7 @@ use Exception; use HiEvents\DomainObjects\Interfaces\DomainObjectInterface; +use HiEvents\Repository\Eloquent\Value\OrderAndDirection; use Illuminate\Contracts\Pagination\Paginator; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Pagination\LengthAwarePaginator; @@ -17,6 +18,9 @@ interface RepositoryInterface /** @var array */ public const DEFAULT_COLUMNS = ['*']; + /** @var string */ + public const DEFAULT_ORDER_DIRECTION = 'asc'; + /** @var int */ public const DEFAULT_PAGINATE_LIMIT = 20; @@ -86,7 +90,6 @@ public function paginateEloquentRelation( * @param int $id * @param array $columns * @return T - * */ public function findById(int $id, array $columns = self::DEFAULT_COLUMNS): DomainObjectInterface; @@ -100,9 +103,15 @@ public function findFirst(int $id, array $columns = self::DEFAULT_COLUMNS): ?Dom /** * @param array $where * @param array $columns + * @param OrderAndDirection[] $orderAndDirections * @return Collection */ - public function findWhere(array $where, array $columns = self::DEFAULT_COLUMNS): Collection; + public function findWhere( + array $where, + array $columns = self::DEFAULT_COLUMNS, + /** @var OrderAndDirection[] */ + array $orderAndDirections = [], + ): Collection; /** * @param array $where @@ -187,4 +196,6 @@ public function incrementWhere(array $where, string $column, int|float $amount = public function decrementEach(array $where, array $columns, array $extra = []): int; public function incrementEach(array $columns, array $additionalUpdates = [], ?array $where = null); + + public function includeDeleted(): static; } diff --git a/backend/app/Repository/Interfaces/TicketPriceRepositoryInterface.php b/backend/app/Repository/Interfaces/TicketPriceRepositoryInterface.php deleted file mode 100644 index 83df3a0bc7..0000000000 --- a/backend/app/Repository/Interfaces/TicketPriceRepositoryInterface.php +++ /dev/null @@ -1,15 +0,0 @@ - - */ -interface TicketPriceRepositoryInterface extends RepositoryInterface -{ -} diff --git a/backend/app/Repository/Interfaces/TicketRepositoryInterface.php b/backend/app/Repository/Interfaces/TicketRepositoryInterface.php deleted file mode 100644 index 4143956a51..0000000000 --- a/backend/app/Repository/Interfaces/TicketRepositoryInterface.php +++ /dev/null @@ -1,89 +0,0 @@ - - */ -interface TicketRepositoryInterface extends RepositoryInterface -{ - /** - * @param int $eventId - * @param QueryParamsDTO $params - * @return LengthAwarePaginator - */ - public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator; - - /** - * @param int $ticketId - * @param int $ticketPriceId - * @return int - */ - public function getQuantityRemainingForTicketPrice(int $ticketId, int $ticketPriceId): int; - - /** - * @param int $ticketId - * @return Collection - */ - public function getTaxesByTicketId(int $ticketId): Collection; - - /** - * @param int $taxId - * @return Collection - */ - public function getTicketsByTaxId(int $taxId): Collection; - - /** - * @param int $ticketId - * @return Collection - */ - public function getCapacityAssignmentsByTicketId(int $ticketId): Collection; - - /** - * @param int $ticketId - * @param array $taxIds - * @return void - */ - public function addTaxesAndFeesToTicket(int $ticketId, array $taxIds): void; - - /** - * @param array $ticketIds - * @param int $capacityAssignmentId - * @return void - */ - public function addCapacityAssignmentToTickets(int $capacityAssignmentId, array $ticketIds): void; - - /** - * @param int $checkInListId - * @param array $ticketIds - * @return void - */ - public function addCheckInListToTickets(int $checkInListId, array $ticketIds): void; - - /** - * @param int $checkInListId - * @return void - */ - public function removeCheckInListFromTickets(int $checkInListId): void; - - /** - * @param int $capacityAssignmentId - * @return void - */ - public function removeCapacityAssignmentFromTickets(int $capacityAssignmentId): void; - - /** - * @param int $eventId - * @param array $orderedTicketIds - * @return void - */ - public function sortTickets(int $eventId, array $orderedTicketIds): void; -} diff --git a/backend/app/Repository/Interfaces/WebhookLogRepositoryInterface.php b/backend/app/Repository/Interfaces/WebhookLogRepositoryInterface.php new file mode 100644 index 0000000000..b9b120b17a --- /dev/null +++ b/backend/app/Repository/Interfaces/WebhookLogRepositoryInterface.php @@ -0,0 +1,14 @@ + + */ +interface WebhookLogRepositoryInterface extends RepositoryInterface +{ + public function deleteOldLogs(int $webhookId, int $numberToKeep = 20): void; +} diff --git a/backend/app/Repository/Interfaces/WebhookRepositoryInterface.php b/backend/app/Repository/Interfaces/WebhookRepositoryInterface.php new file mode 100644 index 0000000000..81479fb8e1 --- /dev/null +++ b/backend/app/Repository/Interfaces/WebhookRepositoryInterface.php @@ -0,0 +1,13 @@ + + */ +interface WebhookRepositoryInterface extends RepositoryInterface +{ +} diff --git a/backend/app/Resources/Account/AccountConfigurationResource.php b/backend/app/Resources/Account/AccountConfigurationResource.php new file mode 100644 index 0000000000..c338691288 --- /dev/null +++ b/backend/app/Resources/Account/AccountConfigurationResource.php @@ -0,0 +1,25 @@ + $this->getId(), + 'name' => $this->getName(), + 'is_system_default' => $this->getIsSystemDefault(), + 'application_fees' => [ + 'percentage' => $this->getPercentageApplicationFee(), + 'fixed' => $this->getFixedApplicationFee(), + ], + ]; + } +} diff --git a/backend/app/Resources/Account/AccountResource.php b/backend/app/Resources/Account/AccountResource.php index 1e7f1229fc..4e708a1aed 100644 --- a/backend/app/Resources/Account/AccountResource.php +++ b/backend/app/Resources/Account/AccountResource.php @@ -21,8 +21,11 @@ public function toArray(Request $request): array 'updated_at' => $this->getUpdatedAt(), 'stripe_connect_setup_complete' => $this->getStripeConnectSetupComplete(), 'is_account_email_confirmed' => $this->getAccountVerifiedAt() !== null, - // this really should not be on the account level 'is_saas_mode_enabled' => config('app.saas_mode_enabled'), + $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/Stripe/StripeConnectAccountResponseResource.php b/backend/app/Resources/Account/Stripe/StripeConnectAccountResponseResource.php index 1f006d9dae..9eb5411da4 100644 --- a/backend/app/Resources/Account/Stripe/StripeConnectAccountResponseResource.php +++ b/backend/app/Resources/Account/Stripe/StripeConnectAccountResponseResource.php @@ -3,7 +3,7 @@ namespace HiEvents\Resources\Account\Stripe; use HiEvents\Resources\Account\AccountResource; -use HiEvents\Services\Handlers\Account\Payment\Stripe\DTO\CreateStripeConnectAccountResponse; +use HiEvents\Services\Application\Handlers\Account\Payment\Stripe\DTO\CreateStripeConnectAccountResponse; use Illuminate\Http\Resources\Json\JsonResource; /** @@ -14,6 +14,7 @@ class StripeConnectAccountResponseResource extends JsonResource public function toArray($request): array { return [ + 'stripe_connect_account_type' => $this->stripeConnectAccountType, 'stripe_account_id' => $this->stripeAccountId, 'is_connect_setup_complete' => $this->isConnectSetupComplete, 'connect_url' => $this->connectUrl, diff --git a/backend/app/Resources/Attendee/AttendeeResource.php b/backend/app/Resources/Attendee/AttendeeResource.php index 819ea89ae9..e1af56f88e 100644 --- a/backend/app/Resources/Attendee/AttendeeResource.php +++ b/backend/app/Resources/Attendee/AttendeeResource.php @@ -7,7 +7,7 @@ use HiEvents\Resources\CheckInList\AttendeeCheckInResource; use HiEvents\Resources\Order\OrderResource; use HiEvents\Resources\Question\QuestionAnswerViewResource; -use HiEvents\Resources\Ticket\TicketResource; +use HiEvents\Resources\Product\ProductResource; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -21,8 +21,8 @@ public function toArray(Request $request): array return [ 'id' => $this->getId(), 'order_id' => $this->getOrderId(), - 'ticket_id' => $this->getTicketId(), - 'ticket_price_id' => $this->getTicketPriceId(), + 'product_id' => $this->getProductId(), + 'product_price_id' => $this->getProductPriceId(), 'event_id' => $this->getEventId(), 'email' => $this->getEmail(), 'status' => $this->getStatus(), @@ -31,14 +31,15 @@ public function toArray(Request $request): array 'public_id' => $this->getPublicId(), 'short_id' => $this->getShortId(), 'locale' => $this->getLocale(), + 'notes' => $this->getNotes(), + 'product' => $this->when( + !is_null($this->getProduct()), + fn() => new ProductResource($this->getProduct()), + ), 'check_in' => $this->when( condition: $this->getCheckIn() !== null, value: fn() => new AttendeeCheckInResource($this->getCheckIn()), ), - 'ticket' => $this->when( - condition: !is_null($this->getTicket()), - value: fn() => new TicketResource($this->getTicket()), - ), 'order' => $this->when( condition: !is_null($this->getOrder()), value: fn() => new OrderResource($this->getOrder()) @@ -47,10 +48,9 @@ public function toArray(Request $request): array condition: $this->getQuestionAndAnswerViews() !== null, value: fn() => QuestionAnswerViewResource::collection( $this->getQuestionAndAnswerViews() - ?->filter(fn($qav) => $qav->getBelongsTo() === QuestionBelongsTo::TICKET->name) + ?->filter(fn($qav) => $qav->getBelongsTo() === QuestionBelongsTo::PRODUCT->name) ) ), - 'created_at' => $this->getCreatedAt(), 'updated_at' => $this->getUpdatedAt(), ]; diff --git a/backend/app/Resources/Attendee/AttendeeResourcePublic.php b/backend/app/Resources/Attendee/AttendeeResourcePublic.php index 8c40ffcd16..1f4ffc1a36 100644 --- a/backend/app/Resources/Attendee/AttendeeResourcePublic.php +++ b/backend/app/Resources/Attendee/AttendeeResourcePublic.php @@ -3,7 +3,7 @@ namespace HiEvents\Resources\Attendee; use HiEvents\DomainObjects\AttendeeDomainObject; -use HiEvents\Resources\Ticket\TicketMinimalResourcePublic; +use HiEvents\Resources\Product\ProductMinimalResourcePublic; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -22,9 +22,9 @@ public function toArray(Request $request): array 'last_name' => $this->getLastName(), 'public_id' => $this->getPublicId(), 'short_id' => $this->getShortId(), - 'ticket_id' => $this->getTicketId(), - 'ticket_price_id' => $this->getTicketPriceId(), - 'ticket' => $this->when((bool)$this->getTicket(), fn() => new TicketMinimalResourcePublic($this->getTicket())), + 'product_id' => $this->getProductId(), + 'product_price_id' => $this->getProductPriceId(), + 'product' => $this->when((bool)$this->getProduct(), fn() => new ProductMinimalResourcePublic($this->getProduct())), 'locale' => $this->getLocale(), ]; } diff --git a/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php b/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php index 9750f8c1f9..82eb7a70e0 100644 --- a/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php +++ b/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php @@ -20,8 +20,9 @@ public function toArray(Request $request): array 'first_name' => $this->getFirstName(), 'last_name' => $this->getLastName(), 'public_id' => $this->getPublicId(), - 'ticket_id' => $this->getTicketId(), - 'ticket_price_id' => $this->getTicketPriceId(), + 'product_id' => $this->getProductId(), + 'product_price_id' => $this->getProductPriceId(), + 'status' => $this->getStatus(), 'locale' => $this->getLocale(), $this->mergeWhen($this->getCheckIn() !== null, [ 'check_in' => new AttendeeCheckInPublicResource($this->getCheckIn()), diff --git a/backend/app/Resources/Auth/AuthenticatedResponseResource.php b/backend/app/Resources/Auth/AuthenticatedResponseResource.php index f1603441b1..0d1cfa6519 100644 --- a/backend/app/Resources/Auth/AuthenticatedResponseResource.php +++ b/backend/app/Resources/Auth/AuthenticatedResponseResource.php @@ -4,7 +4,7 @@ use HiEvents\Resources\Account\AccountResource; use HiEvents\Resources\User\UserResource; -use HiEvents\Services\Handlers\Auth\DTO\AuthenticatedResponseDTO; +use HiEvents\Services\Application\Handlers\Auth\DTO\AuthenticatedResponseDTO; use Illuminate\Http\Resources\Json\JsonResource; /** diff --git a/backend/app/Resources/CapacityAssignment/CapacityAssignmentResource.php b/backend/app/Resources/CapacityAssignment/CapacityAssignmentResource.php index af6af46267..c35c6ef3b3 100644 --- a/backend/app/Resources/CapacityAssignment/CapacityAssignmentResource.php +++ b/backend/app/Resources/CapacityAssignment/CapacityAssignmentResource.php @@ -4,7 +4,7 @@ use HiEvents\DomainObjects\CapacityAssignmentDomainObject; use HiEvents\DomainObjects\Enums\CapacityAssignmentAppliesTo; -use HiEvents\DomainObjects\TicketDomainObject; +use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\Resources\BaseResource; use Illuminate\Http\Request; @@ -25,11 +25,11 @@ public function toArray(Request $request): array 'status' => $this->getStatus(), 'event_id' => $this->getEventId(), $this->mergeWhen( - condition: $this->getTickets() !== null && $this->getAppliesTo() === CapacityAssignmentAppliesTo::TICKETS->name, + condition: $this->getProducts() !== null && $this->getAppliesTo() === CapacityAssignmentAppliesTo::PRODUCTS->name, value: [ - 'tickets' => $this->getTickets()?->map(fn(TicketDomainObject $ticket) => [ - 'id' => $ticket->getId(), - 'title' => $ticket->getTitle(), + 'products' => $this->getProducts()?->map(fn(ProductDomainObject $product) => [ + 'id' => $product->getId(), + 'title' => $product->getTitle(), ]), ]), ]; diff --git a/backend/app/Resources/CheckInList/AttendeeCheckInResource.php b/backend/app/Resources/CheckInList/AttendeeCheckInResource.php index 15246015f9..bae9a3b6d0 100644 --- a/backend/app/Resources/CheckInList/AttendeeCheckInResource.php +++ b/backend/app/Resources/CheckInList/AttendeeCheckInResource.php @@ -3,6 +3,7 @@ namespace HiEvents\Resources\CheckInList; use HiEvents\DomainObjects\AttendeeCheckInDomainObject; +use HiEvents\Resources\Attendee\AttendeeResource; use Illuminate\Http\Resources\Json\JsonResource; /** @@ -16,10 +17,14 @@ public function toArray($request): array 'id' => $this->getId(), 'attendee_id' => $this->getAttendeeId(), 'check_in_list_id' => $this->getCheckInListId(), - 'ticket_id' => $this->getTicketId(), + 'product_id' => $this->getProductId(), 'event_id' => $this->getEventId(), 'short_id' => $this->getShortId(), 'created_at' => $this->getCreatedAt(), + 'attendee' => $this->when( + !is_null($this->getAttendee()), + fn() => (new AttendeeResource($this->getAttendee()))->toArray($request) + ), ]; } } diff --git a/backend/app/Resources/CheckInList/CheckInListResource.php b/backend/app/Resources/CheckInList/CheckInListResource.php index 51d9690216..744f947c70 100644 --- a/backend/app/Resources/CheckInList/CheckInListResource.php +++ b/backend/app/Resources/CheckInList/CheckInListResource.php @@ -3,7 +3,7 @@ namespace HiEvents\Resources\CheckInList; use HiEvents\DomainObjects\CheckInListDomainObject; -use HiEvents\Resources\Ticket\TicketResource; +use HiEvents\Resources\Product\ProductResource; use Illuminate\Http\Resources\Json\JsonResource; /** @@ -26,8 +26,8 @@ public function toArray($request): array 'is_expired' => $this->isExpired($this->getEvent()->getTimezone()), 'is_active' => $this->isActivated($this->getEvent()->getTimezone()), ]), - $this->mergeWhen($this->getTickets() !== null, fn() => [ - 'tickets' => TicketResource::collection($this->getTickets()), + $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 0466870033..7135da87e4 100644 --- a/backend/app/Resources/CheckInList/CheckInListResourcePublic.php +++ b/backend/app/Resources/CheckInList/CheckInListResourcePublic.php @@ -4,7 +4,7 @@ use HiEvents\DomainObjects\CheckInListDomainObject; use HiEvents\Resources\Event\EventResourcePublic; -use HiEvents\Resources\Ticket\TicketMinimalResourcePublic; +use HiEvents\Resources\Product\ProductMinimalResourcePublic; use Illuminate\Http\Resources\Json\JsonResource; /** @@ -28,8 +28,8 @@ public function toArray($request): array 'is_active' => $this->isActivated($this->getEvent()->getTimezone()), 'event' => EventResourcePublic::make($this->getEvent()), ]), - $this->mergeWhen($this->getTickets() !== null, fn() => [ - 'tickets' => TicketMinimalResourcePublic::collection($this->getTickets()), + $this->mergeWhen($this->getProducts() !== null, fn() => [ + 'products' => ProductMinimalResourcePublic::collection($this->getProducts()), ]), ]; } diff --git a/backend/app/Resources/Event/EventResource.php b/backend/app/Resources/Event/EventResource.php index 7fcb425fd9..1a150524ff 100644 --- a/backend/app/Resources/Event/EventResource.php +++ b/backend/app/Resources/Event/EventResource.php @@ -6,7 +6,8 @@ use HiEvents\Resources\BaseResource; use HiEvents\Resources\Image\ImageResource; use HiEvents\Resources\Organizer\OrganizerResource; -use HiEvents\Resources\Ticket\TicketResource; +use HiEvents\Resources\Product\ProductResource; +use HiEvents\Resources\ProductCategory\ProductCategoryResource; use Illuminate\Http\Request; /** @@ -27,21 +28,28 @@ public function toArray(Request $request): array 'currency' => $this->getCurrency(), 'timezone' => $this->getTimezone(), 'slug' => $this->getSlug(), - 'tickets' => $this->when((bool)$this->getTickets(), fn() => TicketResource::collection($this->getTickets())), + 'products' => $this->when( + condition: (bool)$this->getProducts(), + value: fn() => ProductResource::collection($this->getProducts()), + ), + 'product_categories' => $this->when( + condition: (bool)$this->getProductCategories(), + value: fn() => ProductCategoryResource::collection($this->getProductCategories()), + ), 'attributes' => $this->when((bool)$this->getAttributes(), fn() => $this->getAttributes()), 'images' => $this->when((bool)$this->getImages(), fn() => ImageResource::collection($this->getImages())), 'location_details' => $this->when((bool)$this->getLocationDetails(), fn() => $this->getLocationDetails()), 'settings' => $this->when( - !is_null($this->getEventSettings()), - fn() => new EventSettingsResource($this->getEventSettings()) + condition: !is_null($this->getEventSettings()), + value: fn() => new EventSettingsResource($this->getEventSettings()) ), 'organizer' => $this->when( - !is_null($this->getOrganizer()), - fn() => new OrganizerResource($this->getOrganizer()) + condition: !is_null($this->getOrganizer()), + value: fn() => new OrganizerResource($this->getOrganizer()) ), 'statistics' => $this->when( - !is_null($this->getEventStatistics()), - fn() => new EventStatisticsResource($this->getEventStatistics()) + condition: !is_null($this->getEventStatistics()), + value: fn() => new EventStatisticsResource($this->getEventStatistics()) ), ]; } diff --git a/backend/app/Resources/Event/EventResourcePublic.php b/backend/app/Resources/Event/EventResourcePublic.php index d70dc0a103..776003bdc4 100644 --- a/backend/app/Resources/Event/EventResourcePublic.php +++ b/backend/app/Resources/Event/EventResourcePublic.php @@ -6,8 +6,8 @@ use HiEvents\Resources\BaseResource; use HiEvents\Resources\Image\ImageResource; use HiEvents\Resources\Organizer\OrganizerResourcePublic; +use HiEvents\Resources\ProductCategory\ProductCategoryResourcePublic; use HiEvents\Resources\Question\QuestionResource; -use HiEvents\Resources\Ticket\TicketResourcePublic; use Illuminate\Http\Request; /** @@ -38,30 +38,32 @@ public function toArray(Request $request): array 'lifecycle_status' => $this->getLifecycleStatus(), 'timezone' => $this->getTimezone(), 'location_details' => $this->when((bool)$this->getLocationDetails(), fn() => $this->getLocationDetails()), - - 'tickets' => $this->when( - !is_null($this->getTickets()), - fn() => TicketResourcePublic::collection($this->getTickets()) + 'product_categories' => $this->when( + condition: !is_null($this->getProductCategories()) && $this->getProductCategories()->isNotEmpty(), + value: fn() => ProductCategoryResourcePublic::collection($this->getProductCategories()), ), 'settings' => $this->when( - !is_null($this->getEventSettings()), - fn() => new EventSettingsResourcePublic($this->getEventSettings(), $this->includePostCheckoutData), + condition: !is_null($this->getEventSettings()), + value: fn() => new EventSettingsResourcePublic( + $this->getEventSettings(), + $this->includePostCheckoutData + ), ), // @TODO - public question resource 'questions' => $this->when( - !is_null($this->getQuestions()), - fn() => QuestionResource::collection($this->getQuestions()) + condition: !is_null($this->getQuestions()), + value: fn() => QuestionResource::collection($this->getQuestions()) ), 'attributes' => $this->when( - !is_null($this->getAttributes()), - 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( - !is_null($this->getImages()), - fn() => ImageResource::collection($this->getImages()) + condition: !is_null($this->getImages()), + value: fn() => ImageResource::collection($this->getImages()) ), 'organizer' => $this->when( - !is_null($this->getOrganizer()), - fn() => new OrganizerResourcePublic($this->getOrganizer()), + condition: !is_null($this->getOrganizer()), + value: fn() => new OrganizerResourcePublic($this->getOrganizer()), ), ]; } diff --git a/backend/app/Resources/Event/EventSettingsResource.php b/backend/app/Resources/Event/EventSettingsResource.php index 1886a27a22..84e323d207 100644 --- a/backend/app/Resources/Event/EventSettingsResource.php +++ b/backend/app/Resources/Event/EventSettingsResource.php @@ -15,7 +15,7 @@ public function toArray($request): array return [ 'pre_checkout_message' => $this->getPreCheckoutMessage(), 'post_checkout_message' => $this->getPostCheckoutMessage(), - 'ticket_page_message' => $this->getTicketPageMessage(), + 'product_page_message' => $this->getProductPageMessage(), 'continue_button_text' => $this->getContinueButtonText(), 'required_attendee_details' => $this->getRequireAttendeeDetails(), 'email_footer_message' => $this->getEmailFooterMessage(), @@ -46,6 +46,23 @@ public function toArray($request): array 'price_display_mode' => $this->getPriceDisplayMode(), 'hide_getting_started_page' => $this->getHideGettingStartedPage(), + + // Payment settings + 'payment_providers' => $this->getPaymentProviders(), + 'offline_payment_instructions' => $this->getOfflinePaymentInstructions(), + 'allow_orders_awaiting_offline_payment_to_check_in' => $this->getAllowOrdersAwaitingOfflinePaymentToCheckIn(), + + // Invoice settings + 'enable_invoicing' => $this->getEnableInvoicing(), + 'invoice_label' => $this->getInvoiceLabel(), + 'invoice_prefix' => $this->getInvoicePrefix(), + 'invoice_start_number' => $this->getInvoiceStartNumber(), + 'require_billing_address' => $this->getRequireBillingAddress(), + 'organization_name' => $this->getOrganizationName(), + 'organization_address' => $this->getOrganizationAddress(), + 'invoice_tax_details' => $this->getInvoiceTaxDetails(), + 'invoice_notes' => $this->getInvoiceNotes(), + 'invoice_payment_terms_days' => $this->getInvoicePaymentTermsDays(), ]; } } diff --git a/backend/app/Resources/Event/EventSettingsResourcePublic.php b/backend/app/Resources/Event/EventSettingsResourcePublic.php index 397a1d2a29..11792cd9c0 100644 --- a/backend/app/Resources/Event/EventSettingsResourcePublic.php +++ b/backend/app/Resources/Event/EventSettingsResourcePublic.php @@ -23,18 +23,22 @@ public function toArray($request): array return [ 'pre_checkout_message' => $this->getPreCheckoutMessage(), + // We only show post checkout data if the order is completed. So this data is only returned when this + // resource is returned within the context of an order that is completed. + // i.e. order->event->event_settings and not event->event_settings $this->mergeWhen($this->includePostCheckoutData, [ 'post_checkout_message' => $this->getPostCheckoutMessage(), 'online_event_connection_details' => $this->getOnlineEventConnectionDetails(), ]), - 'ticket_page_message' => $this->getTicketPageMessage(), + 'product_page_message' => $this->getProductPageMessage(), 'continue_button_text' => $this->getContinueButtonText(), 'required_attendee_details' => $this->getRequireAttendeeDetails(), 'email_footer_message' => $this->getEmailFooterMessage(), 'support_email' => $this->getSupportEmail(), 'order_timeout_in_minutes' => $this->getOrderTimeoutInMinutes(), + // Homepage settings 'homepage_body_background_color' => $this->getHomepageBodyBackgroundColor(), 'homepage_background_color' => $this->getHomepageBackgroundColor(), 'homepage_primary_color' => $this->getHomepagePrimaryColor(), @@ -49,12 +53,22 @@ public function toArray($request): array 'location_details' => $this->getLocationDetails(), 'is_online_event' => $this->getIsOnlineEvent(), + // SEO settings 'seo_title' => $this->getSeoTitle(), 'seo_description' => $this->getSeoDescription(), 'seo_keywords' => $this->getSeoKeywords(), 'allow_search_engine_indexing' => $this->getAllowSearchEngineIndexing(), 'price_display_mode' => $this->getPriceDisplayMode(), + + // Payment settings + 'payment_providers' => $this->getPaymentProviders(), + 'offline_payment_instructions' => $this->getOfflinePaymentInstructions(), + 'allow_orders_awaiting_offline_payment_to_check_in' => $this->getAllowOrdersAwaitingOfflinePaymentToCheckIn(), + + // Invoice settings + 'require_billing_address' => $this->getRequireBillingAddress(), + 'invoice_label' => $this->getInvoiceLabel(), ]; } } diff --git a/backend/app/Resources/Event/EventStatisticsResource.php b/backend/app/Resources/Event/EventStatisticsResource.php index 91bd7d374f..1b9d5c14fe 100644 --- a/backend/app/Resources/Event/EventStatisticsResource.php +++ b/backend/app/Resources/Event/EventStatisticsResource.php @@ -20,7 +20,8 @@ public function toArray(Request $request): array 'total_tax' => $this->getTotalTax(), 'sales_total_before_additions' => $this->getSalesTotalBeforeAdditions(), 'total_fee' => $this->getTotalFee(), - 'tickets_sold' => $this->getTicketsSold(), + 'products_sold' => $this->getProductsSold(), + 'attendees_registered' => $this->getAttendeesRegistered(), 'total_refunded' => $this->getTotalRefunded(), ]; } diff --git a/backend/app/Resources/Message/MessageResource.php b/backend/app/Resources/Message/MessageResource.php index 75dc30d676..b4b5ce81ef 100644 --- a/backend/app/Resources/Message/MessageResource.php +++ b/backend/app/Resources/Message/MessageResource.php @@ -22,7 +22,7 @@ public function toArray(Request $request): array 'type' => $this->getType(), 'attendee_ids' => $this->getAttendeeIds(), 'order_id' => $this->getOrderId(), - 'ticket_ids' => $this->getTicketIds(), + 'product_ids' => $this->getProductIds(), 'sent_at' => $this->getCreatedAt(), 'status' => $this->getStatus(), 'message_preview' => $this->getMessagePreview(), diff --git a/backend/app/Resources/Order/Invoice/InvoiceResource.php b/backend/app/Resources/Order/Invoice/InvoiceResource.php new file mode 100644 index 0000000000..e0736c4672 --- /dev/null +++ b/backend/app/Resources/Order/Invoice/InvoiceResource.php @@ -0,0 +1,20 @@ + $this->getId(), + 'invoice_number' => $this->getInvoiceNumber(), + 'order_id' => $this->getOrderId(), + 'status' => $this->getStatus(), + ]; + } +} diff --git a/backend/app/Resources/Order/Invoice/InvoiceResourcePublic.php b/backend/app/Resources/Order/Invoice/InvoiceResourcePublic.php new file mode 100644 index 0000000000..b097141f02 --- /dev/null +++ b/backend/app/Resources/Order/Invoice/InvoiceResourcePublic.php @@ -0,0 +1,20 @@ + $this->getId(), + 'invoice_number' => $this->getInvoiceNumber(), + 'order_id' => $this->getOrderId(), + 'status' => $this->getStatus(), + ]; + } +} diff --git a/backend/app/Resources/Order/OrderItemResource.php b/backend/app/Resources/Order/OrderItemResource.php index 18e4155943..3199ca498b 100644 --- a/backend/app/Resources/Order/OrderItemResource.php +++ b/backend/app/Resources/Order/OrderItemResource.php @@ -19,7 +19,7 @@ public function toArray(Request $request): array 'total_before_additions' => $this->getTotalBeforeAdditions(), 'price' => $this->getPrice(), 'quantity' => $this->getQuantity(), - 'ticket_id' => $this->getTicketId(), + 'product_id' => $this->getProductId(), 'item_name' => $this->getItemName(), 'price_before_discount' => $this->getPriceBeforeDiscount(), 'taxes_and_fees_rollup' => $this->getTaxesAndFeesRollup(), diff --git a/backend/app/Resources/Order/OrderItemResourcePublic.php b/backend/app/Resources/Order/OrderItemResourcePublic.php index 471f762f05..18ff026120 100644 --- a/backend/app/Resources/Order/OrderItemResourcePublic.php +++ b/backend/app/Resources/Order/OrderItemResourcePublic.php @@ -4,7 +4,7 @@ use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\Resources\BaseResource; -use HiEvents\Resources\Ticket\TicketResourcePublic; +use HiEvents\Resources\Product\ProductResourcePublic; use Illuminate\Http\Request; /** @@ -22,14 +22,14 @@ public function toArray(Request $request): array 'price' => $this->getPrice(), 'price_before_discount' => $this->getPriceBeforeDiscount(), 'quantity' => $this->getQuantity(), - 'ticket_id' => $this->getTicketId(), - 'ticket_price_id' => $this->getTicketPriceId(), + 'product_id' => $this->getProductId(), + 'product_price_id' => $this->getProductPriceId(), 'item_name' => $this->getItemName(), 'total_service_fee' => $this->getTotalServiceFee(), 'total_tax' => $this->getTotalTax(), 'total_gross' => $this->getTotalGross(), 'taxes_and_fees_rollup' => $this->getTaxesAndFeesRollup(), - 'ticket' => $this->when((bool)$this->getTicket(), fn() => new TicketResourcePublic($this->getTicket())), + 'product' => $this->when((bool)$this->getProduct(), fn() => new ProductResourcePublic($this->getProduct())), ]; } } diff --git a/backend/app/Resources/Order/OrderResource.php b/backend/app/Resources/Order/OrderResource.php index b127d90db2..debc233c9e 100644 --- a/backend/app/Resources/Order/OrderResource.php +++ b/backend/app/Resources/Order/OrderResource.php @@ -2,10 +2,10 @@ namespace HiEvents\Resources\Order; -use HiEvents\DomainObjects\Enums\QuestionBelongsTo; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\Resources\Attendee\AttendeeResource; use HiEvents\Resources\BaseResource; +use HiEvents\Resources\Order\Invoice\InvoiceResource; use HiEvents\Resources\Question\QuestionAnswerViewResource; use Illuminate\Http\Request; @@ -33,12 +33,15 @@ public function toArray(Request $request): array 'email' => $this->getEmail(), 'created_at' => $this->getCreatedAt(), 'public_id' => $this->getPublicId(), - 'payment_gateway' => $this->getPaymentGateway(), 'is_partially_refunded' => $this->isPartiallyRefunded(), 'is_fully_refunded' => $this->isFullyRefunded(), 'is_free_order' => $this->isFreeOrder(), 'is_manually_created' => $this->getIsManuallyCreated(), 'taxes_and_fees_rollup' => $this->getTaxesAndFeesRollup(), + 'address' => $this->getAddress(), + 'notes' => $this->getNotes(), + 'payment_provider' => $this->getPaymentProvider(), + 'promo_code' => $this->getPromoCode(), 'order_items' => $this->when( !is_null($this->getOrderItems()), fn() => OrderItemResource::collection($this->getOrderItems()) @@ -49,10 +52,11 @@ public function toArray(Request $request): array ), 'question_answers' => $this->when( !is_null($this->getQuestionAndAnswerViews()), - fn() => QuestionAnswerViewResource::collection( - $this->getQuestionAndAnswerViews() - ?->filter(fn($qav) => $qav->getBelongsTo() === QuestionBelongsTo::ORDER->name) - ) + fn() => QuestionAnswerViewResource::collection($this->getQuestionAndAnswerViews()), + ), + 'latest_invoice' => $this->when( + !is_null($this->getLatestInvoice()), + fn() => (new InvoiceResource($this->getLatestInvoice()))->toArray($request), ), ]; } diff --git a/backend/app/Resources/Order/OrderResourcePublic.php b/backend/app/Resources/Order/OrderResourcePublic.php index fbda59e73e..4654c50926 100644 --- a/backend/app/Resources/Order/OrderResourcePublic.php +++ b/backend/app/Resources/Order/OrderResourcePublic.php @@ -8,6 +8,8 @@ use HiEvents\Resources\Attendee\AttendeeResourcePublic; use HiEvents\Resources\BaseResource; use HiEvents\Resources\Event\EventResourcePublic; +use HiEvents\Resources\Order\Invoice\InvoiceResource; +use HiEvents\Resources\Order\Invoice\InvoiceResourcePublic; use Illuminate\Http\Request; /** @@ -46,6 +48,10 @@ public function toArray(Request $request): array includePostCheckoutData: $this->getStatus() === OrderStatus::COMPLETED->name, ), ), + 'latest_invoice' => $this->when( + !is_null($this->getLatestInvoice()), + fn() => (new InvoiceResourcePublic($this->getLatestInvoice()))->toArray($request), + ), 'address' => $this->when( !is_null($this->getAddress()), fn() => $this->getAddress() diff --git a/backend/app/Resources/Ticket/TicketMinimalResourcePublic.php b/backend/app/Resources/Product/ProductMinimalResourcePublic.php similarity index 51% rename from backend/app/Resources/Ticket/TicketMinimalResourcePublic.php rename to backend/app/Resources/Product/ProductMinimalResourcePublic.php index 10979bbd8b..daadaab8ba 100644 --- a/backend/app/Resources/Ticket/TicketMinimalResourcePublic.php +++ b/backend/app/Resources/Product/ProductMinimalResourcePublic.php @@ -1,15 +1,15 @@ $this->getType(), 'event_id' => $this->getEventId(), 'prices' => $this->when( - (bool)$this->getTicketPrices(), - fn() => TicketPriceResourcePublic::collection($this->getTicketPrices()), + (bool)$this->getProductPrices(), + fn() => ProductPriceResourcePublic::collection($this->getProductPrices()), ), + 'product_category_id' => $this->getProductCategoryId(), ]; } } diff --git a/backend/app/Resources/Ticket/TicketPriceResource.php b/backend/app/Resources/Product/ProductPriceResource.php similarity index 85% rename from backend/app/Resources/Ticket/TicketPriceResource.php rename to backend/app/Resources/Product/ProductPriceResource.php index 7a4844e394..3cbd945dc5 100644 --- a/backend/app/Resources/Ticket/TicketPriceResource.php +++ b/backend/app/Resources/Product/ProductPriceResource.php @@ -1,15 +1,15 @@ $this->getId(), 'title' => $this->getTitle(), 'type' => $this->getType(), + 'product_type' => $this->getProductType(), 'order' => $this->getOrder(), 'description' => $this->getDescription(), - 'price' => $this->when( - $this->getType() !== TicketType::TIERED->name, - fn() => $this->getPrice() + $this->mergeWhen( + condition: $this->getType() !== ProductPriceType::TIERED->name, + value: fn() => [ + 'price' => $this->getPrice(), + ] ), - 'max_per_order' => $this->getMaxPerOrder() ?? self::DEFAULT_MAX_TICKETS, - 'min_per_order' => $this->getMinPerOrder() ?? self::DEFAULT_MIN_TICKETS, + 'max_per_order' => $this->getMaxPerOrder() ?? self::DEFAULT_MAX_PRODUCTS, + 'min_per_order' => $this->getMinPerOrder() ?? self::DEFAULT_MIN_PRODUCTS, 'quantity_sold' => $this->getQuantitySold(), 'sale_start_date' => $this->getSaleStartDate(), 'sale_end_date' => $this->getSaleEndDate(), @@ -46,7 +49,7 @@ public function toArray(Request $request): array 'is_before_sale_start_date' => $this->isBeforeSaleStartDate(), 'is_after_sale_end_date' => $this->isAfterSaleEndDate(), 'is_available' => $this->isAvailable(), - $this->mergeWhen((bool)$this->getTicketPrices(), fn() => [ + $this->mergeWhen((bool)$this->getProductPrices(), fn() => [ 'is_sold_out' => $this->isSoldOut(), ]), 'taxes_and_fees' => $this->when( @@ -54,9 +57,10 @@ public function toArray(Request $request): array fn() => TaxAndFeeResource::collection($this->getTaxAndFees()) ), 'prices' => $this->when( - (bool)$this->getTicketPrices(), - fn() => TicketPriceResource::collection($this->getTicketPrices()) + (bool)$this->getProductPrices(), + fn() => ProductPriceResource::collection($this->getProductPrices()) ), + 'product_category_id' => $this->getProductCategoryId(), ]; } } diff --git a/backend/app/Resources/Ticket/TicketResourcePublic.php b/backend/app/Resources/Product/ProductResourcePublic.php similarity index 68% rename from backend/app/Resources/Ticket/TicketResourcePublic.php rename to backend/app/Resources/Product/ProductResourcePublic.php index 788ed23cb4..4b8948dd15 100644 --- a/backend/app/Resources/Ticket/TicketResourcePublic.php +++ b/backend/app/Resources/Product/ProductResourcePublic.php @@ -1,16 +1,16 @@ $this->getId(), 'title' => $this->getTitle(), 'type' => $this->getType(), + 'product_type' => $this->getProductType(), 'description' => $this->getDescription(), 'max_per_order' => $this->getMaxPerOrder(), 'min_per_order' => $this->getMinPerOrder(), @@ -31,23 +32,24 @@ public function toArray(Request $request): array 'quantity_available' => $this->getQuantityAvailable(), ]), 'price' => $this->when( - $this->getTicketPrices() && !$this->isTieredType(), + $this->getProductPrices() && !$this->isTieredType(), fn() => $this->getPrice(), ), 'prices' => $this->when( - (bool)$this->getTicketPrices(), - fn() => TicketPriceResourcePublic::collectionWithAdditionalData($this->getTicketPrices(), [ - TicketPriceResourcePublic::SHOW_QUANTITY_AVAILABLE => $this->getShowQuantityRemaining(), + (bool)$this->getProductPrices(), + fn() => ProductPriceResourcePublic::collectionWithAdditionalData($this->getProductPrices(), [ + ProductPriceResourcePublic::SHOW_QUANTITY_AVAILABLE => $this->getShowQuantityRemaining(), ]), ), 'taxes' => $this->when( (bool)$this->getTaxAndFees(), fn() => TaxAndFeeResource::collection($this->getTaxAndFees()) ), - $this->mergeWhen((bool)$this->getTicketPrices(), fn() => [ + $this->mergeWhen((bool)$this->getProductPrices(), fn() => [ 'is_available' => $this->isAvailable(), 'is_sold_out' => $this->isSoldOut(), ]), + 'product_category_id' => $this->getProductCategoryId(), ]; } } diff --git a/backend/app/Resources/ProductCategory/ProductCategoryResource.php b/backend/app/Resources/ProductCategory/ProductCategoryResource.php new file mode 100644 index 0000000000..1d909545b2 --- /dev/null +++ b/backend/app/Resources/ProductCategory/ProductCategoryResource.php @@ -0,0 +1,28 @@ + $this->getId(), + 'name' => $this->getName(), + 'description' => $this->getDescription(), + 'is_hidden' => $this->getIsHidden(), + 'order' => $this->getOrder(), + 'no_products_message' => $this->getNoProductsMessage(), + $this->mergeWhen((bool)$this->getProducts(), fn() => [ + 'products' => ProductResource::collection($this->getProducts()), + ]), + ]; + } +} diff --git a/backend/app/Resources/ProductCategory/ProductCategoryResourcePublic.php b/backend/app/Resources/ProductCategory/ProductCategoryResourcePublic.php new file mode 100644 index 0000000000..bfa087a55c --- /dev/null +++ b/backend/app/Resources/ProductCategory/ProductCategoryResourcePublic.php @@ -0,0 +1,28 @@ + $this->getId(), + 'name' => $this->getName(), + 'description' => $this->getDescription(), + 'is_hidden' => $this->getIsHidden(), + 'order' => $this->getOrder(), + 'no_products_message' => $this->getNoProductsMessage(), + $this->mergeWhen((bool)$this->getProducts(), fn() => [ + 'products' => ProductResourcePublic::collection($this->getProducts()), + ]), + ]; + } +} diff --git a/backend/app/Resources/PromoCode/PromoCodeResource.php b/backend/app/Resources/PromoCode/PromoCodeResource.php index 0aa9d80a54..ec713886ad 100644 --- a/backend/app/Resources/PromoCode/PromoCodeResource.php +++ b/backend/app/Resources/PromoCode/PromoCodeResource.php @@ -16,7 +16,7 @@ public function toArray(Request $request): array return [ 'id' => $this->getId(), 'code' => $this->getCode(), - 'applicable_ticket_ids' => $this->getApplicableTicketIds(), + 'applicable_product_ids' => $this->getApplicableProductIds(), 'discount' => $this->getDiscount(), 'discount_type' => $this->getDiscountType(), 'created_at' => $this->getCreatedAt(), diff --git a/backend/app/Resources/Question/QuestionAnswerViewResource.php b/backend/app/Resources/Question/QuestionAnswerViewResource.php index 6b58307d20..7c3fae4fee 100644 --- a/backend/app/Resources/Question/QuestionAnswerViewResource.php +++ b/backend/app/Resources/Question/QuestionAnswerViewResource.php @@ -16,6 +16,8 @@ class QuestionAnswerViewResource extends JsonResource public function toArray(Request $request): array { return [ + 'product_id' => $this->getProductId(), + 'product_title' => $this->getProductTitle(), 'question_id' => $this->getQuestionId(), 'title' => $this->getTitle(), 'answer' => $this->getAnswer(), diff --git a/backend/app/Resources/Question/QuestionResource.php b/backend/app/Resources/Question/QuestionResource.php index 7f0591dde2..96bdf29651 100644 --- a/backend/app/Resources/Question/QuestionResource.php +++ b/backend/app/Resources/Question/QuestionResource.php @@ -23,9 +23,9 @@ public function toArray(Request $request): array 'event_id' => $this->getEventId(), 'belongs_to' => $this->getBelongsTo(), 'is_hidden' => $this->getIsHidden(), - 'ticket_ids' => $this->when( - !is_null($this->getTickets()), - fn() => $this->getTickets()->map(fn($ticket) => $ticket->getId()) + 'product_ids' => $this->when( + !is_null($this->getProducts()), + fn() => $this->getProducts()->map(fn($product) => $product->getId()) ), ]; } diff --git a/backend/app/Resources/Question/QuestionResourcePublic.php b/backend/app/Resources/Question/QuestionResourcePublic.php index 5820cb8ba8..d4e1288de4 100644 --- a/backend/app/Resources/Question/QuestionResourcePublic.php +++ b/backend/app/Resources/Question/QuestionResourcePublic.php @@ -22,9 +22,9 @@ public function toArray(Request $request): array 'required' => $this->getRequired(), 'event_id' => $this->getEventId(), 'belongs_to' => $this->getBelongsTo(), - 'ticket_ids' => $this->when( - !is_null($this->getTickets()), - fn() => $this->getTickets()->map(fn($ticket) => $ticket->getId()) + 'product_ids' => $this->when( + !is_null($this->getProducts()), + fn() => $this->getProducts()->map(fn($product) => $product->getId()) ), ]; } diff --git a/backend/app/Resources/Webhook/WebhookLogResource.php b/backend/app/Resources/Webhook/WebhookLogResource.php new file mode 100644 index 0000000000..73040e164c --- /dev/null +++ b/backend/app/Resources/Webhook/WebhookLogResource.php @@ -0,0 +1,25 @@ + $this->getId(), + 'webhook_id' => $this->getWebhookId(), + 'payload' => $this->getPayload(), + 'response_body' => $this->getResponseBody(), + 'response_code' => $this->getResponseCode(), + 'created_at' => $this->getCreatedAt(), + 'event_type' => $this->getEventType(), + ]; + } +} diff --git a/backend/app/Resources/Webhook/WebhookResource.php b/backend/app/Resources/Webhook/WebhookResource.php new file mode 100644 index 0000000000..2fe7d9bc35 --- /dev/null +++ b/backend/app/Resources/Webhook/WebhookResource.php @@ -0,0 +1,25 @@ + $this->getId(), + 'url' => $this->getUrl(), + 'event_types' => $this->getEventTypes(), + 'status' => $this->getStatus(), + 'last_triggered_at' => $this->getLastTriggeredAt(), + 'last_response_body' => $this->getLastResponseBody(), + 'last_response_code' => $this->getLastResponseCode(), + ]; + } +} diff --git a/backend/app/Services/Handlers/Account/CreateAccountHandler.php b/backend/app/Services/Application/Handlers/Account/CreateAccountHandler.php similarity index 62% rename from backend/app/Services/Handlers/Account/CreateAccountHandler.php rename to backend/app/Services/Application/Handlers/Account/CreateAccountHandler.php index 2e7c50cfbe..ef68611dc5 100644 --- a/backend/app/Services/Handlers/Account/CreateAccountHandler.php +++ b/backend/app/Services/Application/Handlers/Account/CreateAccountHandler.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HiEvents\Services\Handlers\Account; +namespace HiEvents\Services\Application\Handlers\Account; use HiEvents\DomainObjects\AccountDomainObject; use HiEvents\DomainObjects\Enums\Role; @@ -10,30 +10,35 @@ use HiEvents\DomainObjects\UserDomainObject; use HiEvents\Exceptions\EmailAlreadyExists; use HiEvents\Helper\IdHelper; +use HiEvents\Repository\Interfaces\AccountConfigurationRepositoryInterface; use HiEvents\Repository\Interfaces\AccountRepositoryInterface; use HiEvents\Repository\Interfaces\AccountUserRepositoryInterface; use HiEvents\Repository\Interfaces\UserRepositoryInterface; +use HiEvents\Services\Application\Handlers\Account\DTO\CreateAccountDTO; +use HiEvents\Services\Application\Handlers\Account\Exceptions\AccountConfigurationDoesNotExist; +use HiEvents\Services\Application\Handlers\Account\Exceptions\AccountRegistrationDisabledException; use HiEvents\Services\Domain\Account\AccountUserAssociationService; use HiEvents\Services\Domain\User\EmailConfirmationService; -use HiEvents\Services\Handlers\Account\DTO\CreateAccountDTO; -use HiEvents\Services\Handlers\Account\Exceptions\AccountRegistrationDisabledException; use Illuminate\Config\Repository; use Illuminate\Database\DatabaseManager; use Illuminate\Hashing\HashManager; use NumberFormatter; +use Psr\Log\LoggerInterface; use Throwable; -readonly class CreateAccountHandler +class CreateAccountHandler { public function __construct( - private UserRepositoryInterface $userRepository, - private AccountRepositoryInterface $accountRepository, - private HashManager $hashManager, - private DatabaseManager $databaseManager, - private Repository $config, - private EmailConfirmationService $emailConfirmationService, - private AccountUserAssociationService $accountUserAssociationService, - private AccountUserRepositoryInterface $accountUserRepository, + private readonly UserRepositoryInterface $userRepository, + private readonly AccountRepositoryInterface $accountRepository, + private readonly HashManager $hashManager, + private readonly DatabaseManager $databaseManager, + private readonly Repository $config, + private readonly EmailConfirmationService $emailConfirmationService, + private readonly AccountUserAssociationService $accountUserAssociationService, + private readonly AccountUserRepositoryInterface $accountUserRepository, + private readonly AccountConfigurationRepositoryInterface $accountConfigurationRepository, + private readonly LoggerInterface $logger, ) { } @@ -48,7 +53,7 @@ public function handle(CreateAccountDTO $accountData): AccountDomainObject } $isSaasMode = $this->config->get('app.saas_mode_enabled'); - $passwordHash = $this->hashManager->make($accountData->password); + $passwordHash = $this->hashManager->make($accountData->password);; return $this->databaseManager->transaction(function () use ($isSaasMode, $passwordHash, $accountData) { $account = $this->accountRepository->create([ @@ -60,6 +65,7 @@ public function handle(CreateAccountDTO $accountData): AccountDomainObject // If the app is not running in SaaS mode, we can immediately verify the account. // Same goes for the email verification below. 'account_verified_at' => $isSaasMode ? null : now()->toDateTimeString(), + 'account_configuration_id' => $this->getAccountConfigurationId($accountData), ]); $user = $this->getExistingUser($accountData) ?? $this->userRepository->create([ @@ -139,4 +145,40 @@ private function getExistingUser(CreateAccountDTO $accountData): ?UserDomainObje return $existingUser; } + + /** + * @throws AccountConfigurationDoesNotExist + */ + private function getAccountConfigurationId(CreateAccountDTO $accountData): int + { + if ($accountData->invite_token !== null) { + $decryptedInviteToken = decrypt($accountData->invite_token); + $accountConfigurationId = $decryptedInviteToken['account_configuration_id']; + + $accountConfiguration = $this->accountConfigurationRepository->findFirstWhere([ + 'id' => $accountConfigurationId, + ]); + + if ($accountConfiguration !== null) { + return $accountConfiguration->getId(); + } + + $this->logger->error('Invalid account configuration ID in invite token', [ + 'account_configuration_id' => $accountConfigurationId, + ]); + } + + $defaultConfiguration = $this->accountConfigurationRepository->findFirstWhere([ + 'is_system_default' => true, + ]); + + if ($defaultConfiguration === null) { + $this->logger->error('No default account configuration found'); + throw new AccountConfigurationDoesNotExist( + __('There is no default account configuration available') + ); + } + + return $defaultConfiguration->getId(); + } } diff --git a/backend/app/Services/Handlers/Account/DTO/CreateAccountDTO.php b/backend/app/Services/Application/Handlers/Account/DTO/CreateAccountDTO.php similarity index 80% rename from backend/app/Services/Handlers/Account/DTO/CreateAccountDTO.php rename to backend/app/Services/Application/Handlers/Account/DTO/CreateAccountDTO.php index 94322a2c0d..1f6b16852d 100644 --- a/backend/app/Services/Handlers/Account/DTO/CreateAccountDTO.php +++ b/backend/app/Services/Application/Handlers/Account/DTO/CreateAccountDTO.php @@ -1,6 +1,6 @@ type, stripeAccountId: $stripeConnectAccount->id, account: $account, isConnectSetupComplete: $this->isStripeAccountComplete($stripeConnectAccount), @@ -80,7 +83,8 @@ private function getOrCreateStripeConnectAccount(AccountDomainObject $account): } $stripeAccount = $this->stripe->accounts->create([ - 'type' => 'express', + 'type' => $this->config->get('app.stripe_connect_account_type') + ?? StripeConnectAccountType::EXPRESS->value, ]); } catch (Throwable $e) { $this->logger->error('Failed to create or fetch Stripe Connect Account: ' . $e->getMessage(), [ @@ -98,7 +102,8 @@ private function getOrCreateStripeConnectAccount(AccountDomainObject $account): $this->accountRepository->updateWhere( attributes: [ - 'stripe_account_id' => $stripeAccount->id, + AccountDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id, + AccountDomainObjectAbstract::STRIPE_CONNECT_ACCOUNT_TYPE => $stripeAccount->type, ], where: [ 'id' => $account->getId(), diff --git a/backend/app/Services/Handlers/Account/Payment/Stripe/DTO/CreateStripeConnectAccountDTO.php b/backend/app/Services/Application/Handlers/Account/Payment/Stripe/DTO/CreateStripeConnectAccountDTO.php similarity index 72% rename from backend/app/Services/Handlers/Account/Payment/Stripe/DTO/CreateStripeConnectAccountDTO.php rename to backend/app/Services/Application/Handlers/Account/Payment/Stripe/DTO/CreateStripeConnectAccountDTO.php index 3dd29f0a30..38a4ff3a8c 100644 --- a/backend/app/Services/Handlers/Account/Payment/Stripe/DTO/CreateStripeConnectAccountDTO.php +++ b/backend/app/Services/Application/Handlers/Account/Payment/Stripe/DTO/CreateStripeConnectAccountDTO.php @@ -1,6 +1,6 @@ createOrder($attendeeDTO->event_id, $attendeeDTO); - /** @var TicketDomainObject $ticket */ - $ticket = $this->ticketRepository - ->loadRelation(TicketPriceDomainObject::class) + /** @var ProductDomainObject $product */ + $product = $this->productRepository + ->loadRelation(ProductPriceDomainObject::class) ->findFirstWhere([ - TicketDomainObjectAbstract::ID => $attendeeDTO->ticket_id, - TicketDomainObjectAbstract::EVENT_ID => $attendeeDTO->event_id, + ProductDomainObjectAbstract::ID => $attendeeDTO->product_id, + ProductDomainObjectAbstract::EVENT_ID => $attendeeDTO->event_id, + ProductDomainObjectAbstract::PRODUCT_TYPE => ProductType::TICKET->name, ]); - $ticketPriceId = $this->getTicketPriceId($attendeeDTO, $ticket); + if (!$product) { + throw new NoTicketsAvailableException(__('This ticket is invalid')); + } + + $productPriceId = $this->getProductPriceId($attendeeDTO, $product); - $availableQuantity = $this->ticketRepository->getQuantityRemainingForTicketPrice( - $attendeeDTO->ticket_id, - $ticketPriceId, + $availableQuantity = $this->productRepository->getQuantityRemainingForProductPrice( + $attendeeDTO->product_id, + $productPriceId, ); if ($availableQuantity <= 0) { throw new NoTicketsAvailableException(__('There are no tickets available. ' . - 'If you would like to assign a ticket to this attendee,' . - ' please adjust the ticket\'s available quantity.')); + '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, $ticket, $ticketPriceId); + $orderItem = $this->createOrderItem($attendeeDTO, $order, $product, $productPriceId); $attendee = $this->createAttendee($order, $attendeeDTO); @@ -93,6 +103,8 @@ public function handle(CreateAttendeeDTO $attendeeDTO): AttendeeDomainObject $this->fireEventsAndUpdateQuantities($attendeeDTO, $order); + $this->queueWebhooks($attendee, $order); + return $attendee; }); } @@ -123,27 +135,27 @@ private function createOrder(int $eventId, CreateAttendeeDTO $attendeeDTO): Orde } /** - * @throws InvalidTicketPriceId + * @throws InvalidProductPriceId */ - private function getTicketPriceId(CreateAttendeeDTO $attendeeDTO, TicketDomainObject $ticket): int + private function getProductPriceId(CreateAttendeeDTO $attendeeDTO, ProductDomainObject $product): int { - $priceIds = $ticket->getTicketPrices()->map(fn(TicketPriceDomainObject $ticketPrice) => $ticketPrice->getId()); + $priceIds = $product->getProductPrices()->map(fn(ProductPriceDomainObject $productPrice) => $productPrice->getId()); - if ($attendeeDTO->ticket_price_id) { - if (!$priceIds->contains($attendeeDTO->ticket_price_id)) { - throw new InvalidTicketPriceId(__('The ticket price ID is invalid.')); + if ($attendeeDTO->product_price_id) { + if (!$priceIds->contains($attendeeDTO->product_price_id)) { + throw new InvalidProductPriceId(__('The product price ID is invalid.')); } - return $attendeeDTO->ticket_price_id; + return $attendeeDTO->product_price_id; } - /** @var TicketPriceDomainObject $ticketPrice */ - $ticketPrice = $ticket->getTicketPrices()->first(); + /** @var ProductPriceDomainObject $productPrice */ + $productPrice = $product->getProductPrices()->first(); - if ($ticketPrice) { - return $ticketPrice->getId(); + if ($productPrice) { + return $productPrice->getId(); } - throw new InvalidTicketPriceId(__('The ticket price ID is invalid.')); + throw new InvalidProductPriceId(__('The product price ID is invalid.')); } private function calculateTaxesAndFees(CreateAttendeeDTO $attendeeDTO): ?Collection @@ -187,11 +199,11 @@ private function processTaxesAndFees(CreateAttendeeDTO $attendeeDTO): void ); } - private function createOrderItem(CreateAttendeeDTO $attendeeDTO, OrderDomainObject $order, TicketDomainObject $ticket, int $ticketPriceId): OrderItemDomainObject + private function createOrderItem(CreateAttendeeDTO $attendeeDTO, OrderDomainObject $order, ProductDomainObject $product, int $productPriceId): OrderItemDomainObject { return $this->orderRepository->addOrderItem( [ - OrderItemDomainObjectAbstract::TICKET_ID => $attendeeDTO->ticket_id, + OrderItemDomainObjectAbstract::PRODUCT_ID => $attendeeDTO->product_id, OrderItemDomainObjectAbstract::QUANTITY => 1, OrderItemDomainObjectAbstract::TOTAL_BEFORE_ADDITIONS => $attendeeDTO->amount_paid, OrderItemDomainObjectAbstract::TOTAL_GROSS => $attendeeDTO->amount_paid + $this->taxAndFeeRollupService->getTotalTaxesAndFees(), @@ -199,8 +211,8 @@ private function createOrderItem(CreateAttendeeDTO $attendeeDTO, OrderDomainObje OrderItemDomainObjectAbstract::TOTAL_SERVICE_FEE => $this->taxAndFeeRollupService->getTotalFees(), OrderItemDomainObjectAbstract::PRICE => $attendeeDTO->amount_paid, OrderItemDomainObjectAbstract::ORDER_ID => $order->getId(), - OrderItemDomainObjectAbstract::ITEM_NAME => $ticket->getTitle(), - OrderItemDomainObjectAbstract::TICKET_PRICE_ID => $ticketPriceId, + OrderItemDomainObjectAbstract::ITEM_NAME => $product->getTitle(), + OrderItemDomainObjectAbstract::PRODUCT_PRICE_ID => $productPriceId, OrderItemDomainObjectAbstract::TAXES_AND_FEES_ROLLUP => $this->taxAndFeeRollupService->getRollUp(), ] ); @@ -210,8 +222,8 @@ private function createAttendee(OrderDomainObject $order, CreateAttendeeDTO $att { return $this->attendeeRepository->create([ AttendeeDomainObjectAbstract::EVENT_ID => $order->getEventId(), - AttendeeDomainObjectAbstract::TICKET_ID => $attendeeDTO->ticket_id, - AttendeeDomainObjectAbstract::TICKET_PRICE_ID => $attendeeDTO->ticket_price_id, + AttendeeDomainObjectAbstract::PRODUCT_ID => $attendeeDTO->product_id, + AttendeeDomainObjectAbstract::PRODUCT_PRICE_ID => $attendeeDTO->product_price_id, AttendeeDomainObjectAbstract::STATUS => AttendeeStatus::ACTIVE->name, AttendeeDomainObjectAbstract::EMAIL => $attendeeDTO->email, AttendeeDomainObjectAbstract::FIRST_NAME => $attendeeDTO->first_name, @@ -225,8 +237,8 @@ private function createAttendee(OrderDomainObject $order, CreateAttendeeDTO $att private function fireEventsAndUpdateQuantities(CreateAttendeeDTO $attendeeDTO, OrderDomainObject $order): void { - $this->ticketQuantityAdjustmentService->increaseQuantitySold( - priceId: $attendeeDTO->ticket_price_id, + $this->productQuantityAdjustmentService->increaseQuantitySold( + priceId: $attendeeDTO->product_price_id, ); event(new OrderStatusChangedEvent( @@ -234,4 +246,12 @@ private function fireEventsAndUpdateQuantities(CreateAttendeeDTO $attendeeDTO, O sendEmails: $attendeeDTO->send_confirmation_email, )); } + + private function queueWebhooks(AttendeeDomainObject $attendee, OrderDomainObject $order): void + { + $this->webhookDispatchService->queueOrderWebhook( + eventType: WebhookEventType::ORDER_CREATED, + orderId: $order->getId(), + ); + } } diff --git a/backend/app/Services/Handlers/Attendee/DTO/CheckInAttendeeDTO.php b/backend/app/Services/Application/Handlers/Attendee/DTO/CheckInAttendeeDTO.php similarity index 82% rename from backend/app/Services/Handlers/Attendee/DTO/CheckInAttendeeDTO.php rename to backend/app/Services/Application/Handlers/Attendee/DTO/CheckInAttendeeDTO.php index 6a1b0810d5..60c75ae99e 100644 --- a/backend/app/Services/Handlers/Attendee/DTO/CheckInAttendeeDTO.php +++ b/backend/app/Services/Application/Handlers/Attendee/DTO/CheckInAttendeeDTO.php @@ -1,6 +1,6 @@ databaseManager->transaction(function () use ($editAttendeeDTO) { + $this->validateProductId($editAttendeeDTO); + + $attendee = $this->getAttendee($editAttendeeDTO); + + $this->adjustProductQuantities($attendee, $editAttendeeDTO); + + $updatedAttendee = $this->updateAttendee($editAttendeeDTO); + + $this->webhookDispatchService->queueAttendeeWebhook( + eventType: WebhookEventType::ATTENDEE_UPDATED, + attendeeId: $updatedAttendee->getId(), + ); + + return $updatedAttendee; + }); + } + + 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); + } + } + + private function updateAttendee(EditAttendeeDTO $editAttendeeDTO): AttendeeDomainObject + { + return $this->attendeeRepository->updateByIdWhere($editAttendeeDTO->attendee_id, [ + 'first_name' => $editAttendeeDTO->first_name, + 'last_name' => $editAttendeeDTO->last_name, + 'email' => $editAttendeeDTO->email, + 'product_id' => $editAttendeeDTO->product_id, + 'product_price_id' => $editAttendeeDTO->product_price_id, + 'notes' => $editAttendeeDTO->notes, + ], [ + 'event_id' => $editAttendeeDTO->event_id, + ]); + } + + /** + * @throws ValidationException + * @throws NoTicketsAvailableException + */ + private function validateProductId(EditAttendeeDTO $editAttendeeDTO): void + { + /** @var ProductDomainObject $product */ + $product = $this->productRepository + ->loadRelation(ProductPriceDomainObject::class) + ->findFirstWhere([ + ProductDomainObjectAbstract::ID => $editAttendeeDTO->product_id, + ]); + + if ($product->getEventId() !== $editAttendeeDTO->event_id) { + throw ValidationException::withMessages([ + 'product_id' => __('Product ID is not valid'), + ]); + } + + $productPriceIds = $product->getProductPrices()->map(fn($productPrice) => $productPrice->getId())->toArray(); + if (!in_array($editAttendeeDTO->product_price_id, $productPriceIds, true)) { + throw ValidationException::withMessages([ + 'product_price_id' => __('Product price ID is not valid'), + ]); + } + + $availableQuantity = $this->productRepository->getQuantityRemainingForProductPrice( + productId: $editAttendeeDTO->product_id, + productPriceId: $product->getType() === ProductPriceType::TIERED->name + ? $editAttendeeDTO->product_price_id + : $product->getProductPrices()->first()->getId(), + ); + + if ($availableQuantity <= 0) { + throw new NoTicketsAvailableException( + __('There are no products available. If you would like to assign this product to this attendee, please adjust the product\'s available quantity.') + ); + } + } + + /** + * @throws ValidationException + */ + private function getAttendee(EditAttendeeDTO $editAttendeeDTO): AttendeeDomainObject + { + $attendee = $this->attendeeRepository->findFirstWhere([ + AttendeeDomainObjectAbstract::EVENT_ID => $editAttendeeDTO->event_id, + AttendeeDomainObjectAbstract::ID => $editAttendeeDTO->attendee_id, + ]); + + if ($attendee === null) { + throw ValidationException::withMessages([ + 'attendee_id' => __('Attendee ID is not valid'), + ]); + } + + return $attendee; + } +} diff --git a/backend/app/Services/Handlers/Attendee/PartialEditAttendeeHandler.php b/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php similarity index 54% rename from backend/app/Services/Handlers/Attendee/PartialEditAttendeeHandler.php rename to backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php index 5717bddfb7..316899d88b 100644 --- a/backend/app/Services/Handlers/Attendee/PartialEditAttendeeHandler.php +++ b/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php @@ -1,12 +1,14 @@ status && $data->status !== $attendee->getStatus()) { - $this->adjustTicketQuantity($data, $attendee); + $statusIsUpdated = $data->status && $data->status !== $attendee->getStatus(); + + if ($statusIsUpdated) { + $this->adjustProductQuantity($data, $attendee); + } + + if ($statusIsUpdated && $data->status === AttendeeStatus::CANCELLED->name) { + $this->webhookDispatchService->queueAttendeeWebhook( + eventType: WebhookEventType::ATTENDEE_CANCELLED, + attendeeId: $attendee->getId(), + ); } return $this->attendeeRepository->updateByIdWhere( @@ -62,14 +74,14 @@ private function updateAttendee(PartialEditAttendeeDTO $data): AttendeeDomainObj } /** - * @todo - we should check ticket availability before updating the ticket quantity + * @todo - we should check product availability before updating the product quantity */ - private function adjustTicketQuantity(PartialEditAttendeeDTO $data, AttendeeDomainObject $attendee): void + private function adjustProductQuantity(PartialEditAttendeeDTO $data, AttendeeDomainObject $attendee): void { if ($data->status === AttendeeStatus::ACTIVE->name) { - $this->ticketQuantityService->increaseQuantitySold($attendee->getTicketPriceId()); + $this->productQuantityService->increaseQuantitySold($attendee->getProductPriceId()); } elseif ($data->status === AttendeeStatus::CANCELLED->name) { - $this->ticketQuantityService->decreaseQuantitySold($attendee->getTicketPriceId()); + $this->productQuantityService->decreaseQuantitySold($attendee->getProductPriceId()); } } } diff --git a/backend/app/Services/Handlers/Attendee/ResendAttendeeTicketHandler.php b/backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php similarity index 66% rename from backend/app/Services/Handlers/Attendee/ResendAttendeeTicketHandler.php rename to backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php index 1424a16293..7ef12805ef 100644 --- a/backend/app/Services/Handlers/Attendee/ResendAttendeeTicketHandler.php +++ b/backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php @@ -1,23 +1,24 @@ attendeeRepository->findFirstWhere([ - 'id' => $resendAttendeeTicketDTO->attendeeId, - 'event_id' => $resendAttendeeTicketDTO->eventId, - ]); + $attendee = $this->attendeeRepository + ->loadRelation(new Relationship(OrderDomainObject::class, name: 'order')) + ->findFirstWhere([ + 'id' => $resendAttendeeProductDTO->attendeeId, + 'event_id' => $resendAttendeeProductDTO->eventId, + ]); if (!$attendee) { throw new ResourceNotFoundException(); @@ -46,9 +49,10 @@ public function handle(ResendAttendeeTicketDTO $resendAttendeeTicketDTO): void $event = $this->eventRepository ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) ->loadRelation(EventSettingDomainObject::class) - ->findById($resendAttendeeTicketDTO->eventId); + ->findById($resendAttendeeProductDTO->eventId); - $this->sendAttendeeTicketService->send( + $this->sendAttendeeProductService->send( + order: $attendee->getOrder(), attendee: $attendee, event: $event, eventSettings: $event->getEventSettings(), @@ -56,8 +60,8 @@ public function handle(ResendAttendeeTicketDTO $resendAttendeeTicketDTO): void ); $this->logger->info('Attendee ticket resent', [ - 'attendeeId' => $resendAttendeeTicketDTO->attendeeId, - 'eventId' => $resendAttendeeTicketDTO->eventId + 'attendeeId' => $resendAttendeeProductDTO->attendeeId, + 'eventId' => $resendAttendeeProductDTO->eventId ]); } } diff --git a/backend/app/Services/Handlers/Auth/AcceptInvitationHandler.php b/backend/app/Services/Application/Handlers/Auth/AcceptInvitationHandler.php similarity index 95% rename from backend/app/Services/Handlers/Auth/AcceptInvitationHandler.php rename to backend/app/Services/Application/Handlers/Auth/AcceptInvitationHandler.php index b1709bbc77..fd46007ea7 100644 --- a/backend/app/Services/Handlers/Auth/AcceptInvitationHandler.php +++ b/backend/app/Services/Application/Handlers/Auth/AcceptInvitationHandler.php @@ -1,12 +1,12 @@ setName($data->name) ->setEventId($data->event_id) ->setCapacity($data->capacity) - ->setAppliesTo(CapacityAssignmentAppliesTo::TICKETS->name) + ->setAppliesTo(CapacityAssignmentAppliesTo::PRODUCTS->name) ->setStatus($data->status->name); return $this->createCapacityAssignmentService->createCapacityAssignment( $capacityAssignment, - $data->ticket_ids, + $data->product_ids, ); } } diff --git a/backend/app/Services/Handlers/CapacityAssignment/DTO/GetCapacityAssignmentsDTO.php b/backend/app/Services/Application/Handlers/CapacityAssignment/DTO/GetCapacityAssignmentsDTO.php similarity index 79% rename from backend/app/Services/Handlers/CapacityAssignment/DTO/GetCapacityAssignmentsDTO.php rename to backend/app/Services/Application/Handlers/CapacityAssignment/DTO/GetCapacityAssignmentsDTO.php index 7cc7da5e5d..a7d8752f80 100644 --- a/backend/app/Services/Handlers/CapacityAssignment/DTO/GetCapacityAssignmentsDTO.php +++ b/backend/app/Services/Application/Handlers/CapacityAssignment/DTO/GetCapacityAssignmentsDTO.php @@ -1,6 +1,6 @@ databaseManager->transaction(function () use ($id, $eventId) { - $this->ticketRepository->removeCapacityAssignmentFromTickets( + $this->productRepository->removeCapacityAssignmentFromProducts( capacityAssignmentId: $id, ); diff --git a/backend/app/Services/Handlers/CapacityAssignment/GetCapacityAssignmentHandler.php b/backend/app/Services/Application/Handlers/CapacityAssignment/GetCapacityAssignmentHandler.php similarity index 83% rename from backend/app/Services/Handlers/CapacityAssignment/GetCapacityAssignmentHandler.php rename to backend/app/Services/Application/Handlers/CapacityAssignment/GetCapacityAssignmentHandler.php index cbdf770f03..a8340b6ee5 100644 --- a/backend/app/Services/Handlers/CapacityAssignment/GetCapacityAssignmentHandler.php +++ b/backend/app/Services/Application/Handlers/CapacityAssignment/GetCapacityAssignmentHandler.php @@ -1,9 +1,9 @@ capacityAssignmentRepository - ->loadRelation(TicketDomainObject::class) + ->loadRelation(ProductDomainObject::class) ->findFirstWhere([ 'event_id' => $eventId, 'id' => $capacityAssignmentId, diff --git a/backend/app/Services/Handlers/CapacityAssignment/GetCapacityAssignmentsHandler.php b/backend/app/Services/Application/Handlers/CapacityAssignment/GetCapacityAssignmentsHandler.php similarity index 69% rename from backend/app/Services/Handlers/CapacityAssignment/GetCapacityAssignmentsHandler.php rename to backend/app/Services/Application/Handlers/CapacityAssignment/GetCapacityAssignmentsHandler.php index e4d0434b3b..215ffe567c 100644 --- a/backend/app/Services/Handlers/CapacityAssignment/GetCapacityAssignmentsHandler.php +++ b/backend/app/Services/Application/Handlers/CapacityAssignment/GetCapacityAssignmentsHandler.php @@ -1,10 +1,10 @@ capacityAssignmentRepository - ->loadRelation(TicketDomainObject::class) + ->loadRelation(ProductDomainObject::class) ->findByEventId( eventId: $dto->eventId, params: $dto->queryParams, diff --git a/backend/app/Services/Handlers/CapacityAssignment/UpdateCapacityAssignmentHandler.php b/backend/app/Services/Application/Handlers/CapacityAssignment/UpdateCapacityAssignmentHandler.php similarity index 69% rename from backend/app/Services/Handlers/CapacityAssignment/UpdateCapacityAssignmentHandler.php rename to backend/app/Services/Application/Handlers/CapacityAssignment/UpdateCapacityAssignmentHandler.php index 9c330d859b..eedf3eef6d 100644 --- a/backend/app/Services/Handlers/CapacityAssignment/UpdateCapacityAssignmentHandler.php +++ b/backend/app/Services/Application/Handlers/CapacityAssignment/UpdateCapacityAssignmentHandler.php @@ -1,12 +1,12 @@ setName($data->name) ->setEventId($data->event_id) ->setCapacity($data->capacity) - ->setAppliesTo(CapacityAssignmentAppliesTo::TICKETS->name) + ->setAppliesTo(CapacityAssignmentAppliesTo::PRODUCTS->name) ->setStatus($data->status->name); return $this->updateCapacityAssignmentService->updateCapacityAssignment( $capacityAssignment, - $data->ticket_ids, + $data->product_ids, ); } } diff --git a/backend/app/Services/Handlers/CheckInList/CreateCheckInListHandler.php b/backend/app/Services/Application/Handlers/CheckInList/CreateCheckInListHandler.php similarity index 72% rename from backend/app/Services/Handlers/CheckInList/CreateCheckInListHandler.php rename to backend/app/Services/Application/Handlers/CheckInList/CreateCheckInListHandler.php index a3d5ed4554..6682b2a346 100644 --- a/backend/app/Services/Handlers/CheckInList/CreateCheckInListHandler.php +++ b/backend/app/Services/Application/Handlers/CheckInList/CreateCheckInListHandler.php @@ -1,11 +1,11 @@ createCheckInListService->createCheckInList( checkInList: $checkInList, - ticketIds: $listData->ticketIds + productIds: $listData->productIds ); } } diff --git a/backend/app/Services/Handlers/CheckInList/DTO/GetCheckInListsDTO.php b/backend/app/Services/Application/Handlers/CheckInList/DTO/GetCheckInListsDTO.php similarity index 80% rename from backend/app/Services/Handlers/CheckInList/DTO/GetCheckInListsDTO.php rename to backend/app/Services/Application/Handlers/CheckInList/DTO/GetCheckInListsDTO.php index ebc04d3d54..6815859758 100644 --- a/backend/app/Services/Handlers/CheckInList/DTO/GetCheckInListsDTO.php +++ b/backend/app/Services/Application/Handlers/CheckInList/DTO/GetCheckInListsDTO.php @@ -1,6 +1,6 @@ checkInListRepository - ->loadRelation(TicketDomainObject::class) + ->loadRelation(ProductDomainObject::class) ->loadRelation(new Relationship(domainObject: EventDomainObject::class, name: 'event')) ->findFirstWhere([ 'event_id' => $eventId, diff --git a/backend/app/Services/Handlers/CheckInList/GetCheckInListsHandler.php b/backend/app/Services/Application/Handlers/CheckInList/GetCheckInListsHandler.php similarity index 87% rename from backend/app/Services/Handlers/CheckInList/GetCheckInListsHandler.php rename to backend/app/Services/Application/Handlers/CheckInList/GetCheckInListsHandler.php index be46595b23..3725d05ff7 100644 --- a/backend/app/Services/Handlers/CheckInList/GetCheckInListsHandler.php +++ b/backend/app/Services/Application/Handlers/CheckInList/GetCheckInListsHandler.php @@ -1,13 +1,13 @@ checkInListRepository - ->loadRelation(TicketDomainObject::class) + ->loadRelation(ProductDomainObject::class) ->loadRelation(new Relationship(domainObject: EventDomainObject::class, name: 'event')) ->findByEventId( eventId: $dto->eventId, diff --git a/backend/app/Services/Handlers/CheckInList/Public/CreateAttendeeCheckInPublicHandler.php b/backend/app/Services/Application/Handlers/CheckInList/Public/CreateAttendeeCheckInPublicHandler.php similarity index 61% rename from backend/app/Services/Handlers/CheckInList/Public/CreateAttendeeCheckInPublicHandler.php rename to backend/app/Services/Application/Handlers/CheckInList/Public/CreateAttendeeCheckInPublicHandler.php index de22ea2fcd..11b6e12f20 100644 --- a/backend/app/Services/Handlers/CheckInList/Public/CreateAttendeeCheckInPublicHandler.php +++ b/backend/app/Services/Application/Handlers/CheckInList/Public/CreateAttendeeCheckInPublicHandler.php @@ -1,32 +1,36 @@ createAttendeeCheckInService->checkInAttendees( $checkInData->checkInListUuid, $checkInData->checkInUserIpAddress, - $checkInData->attendeePublicIds, + $checkInData->attendeesAndActions, ); $this->logger->info('Attendee check-ins created', [ @@ -36,6 +40,14 @@ public function handle(CreateAttendeeCheckInPublicDTO $checkInData): CreateAtten 'ip_address' => $checkInData->checkInUserIpAddress, ]); + /** @var AttendeeCheckInDomainObject $checkIn */ + foreach ($checkIns->attendeeCheckIns as $checkIn) { + $this->webhookDispatchService->queueCheckInWebhook( + WebhookEventType::CHECKIN_CREATED, + $checkIn->getId(), + ); + } + return $checkIns; } } diff --git a/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/AttendeeAndActionDTO.php b/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/AttendeeAndActionDTO.php new file mode 100644 index 0000000000..3f08384c72 --- /dev/null +++ b/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/AttendeeAndActionDTO.php @@ -0,0 +1,16 @@ +deleteAttendeeCheckInService->deleteAttendeeCheckIn( + $deletedCheckInId = $this->deleteAttendeeCheckInService->deleteAttendeeCheckIn( $checkInData->checkInListShortId, $checkInData->checkInShortId, ); @@ -31,5 +34,10 @@ public function handle(DeleteAttendeeCheckInPublicDTO $checkInData): void 'attendee_public_id' => $checkInData->checkInShortId, 'check_in_user_ip_address' => $checkInData->checkInUserIpAddress, ]); + + $this->webhookDispatchService->queueCheckInWebhook( + eventType: WebhookEventType::CHECKIN_DELETED, + attendeeCheckInId: $deletedCheckInId, + ); } } diff --git a/backend/app/Services/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandler.php b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandler.php similarity index 92% rename from backend/app/Services/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandler.php rename to backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandler.php index de6332ea0b..a59e516ca4 100644 --- a/backend/app/Services/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandler.php +++ b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandler.php @@ -1,11 +1,11 @@ checkInListRepository - ->loadRelation(TicketDomainObject::class) + ->loadRelation(ProductDomainObject::class) ->loadRelation(new Relationship(EventDomainObject::class, name: 'event')) ->findFirstWhere([ CheckInListDomainObjectAbstract::SHORT_ID => $shortId, diff --git a/backend/app/Services/Handlers/CheckInList/Public/GetCheckInListPublicHandler.php b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListPublicHandler.php similarity index 71% rename from backend/app/Services/Handlers/CheckInList/Public/GetCheckInListPublicHandler.php rename to backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListPublicHandler.php index bbd24480a0..ffee8de664 100644 --- a/backend/app/Services/Handlers/CheckInList/Public/GetCheckInListPublicHandler.php +++ b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListPublicHandler.php @@ -1,10 +1,11 @@ checkInListRepository - ->loadRelation(new Relationship(domainObject: EventDomainObject::class, name: 'event')) - ->loadRelation(TicketDomainObject::class) + ->loadRelation((new Relationship(domainObject: EventDomainObject::class, nested: [ + new Relationship(domainObject: EventSettingDomainObject::class, name: 'event_settings'), + ], name: 'event'))) + ->loadRelation(ProductDomainObject::class) ->findFirstWhere([ 'short_id' => $shortId, ]); diff --git a/backend/app/Services/Handlers/CheckInList/UpdateCheckInlistHandler.php b/backend/app/Services/Application/Handlers/CheckInList/UpdateCheckInlistHandler.php similarity index 72% rename from backend/app/Services/Handlers/CheckInList/UpdateCheckInlistHandler.php rename to backend/app/Services/Application/Handlers/CheckInList/UpdateCheckInlistHandler.php index 4430781712..d31d7873c3 100644 --- a/backend/app/Services/Handlers/CheckInList/UpdateCheckInlistHandler.php +++ b/backend/app/Services/Application/Handlers/CheckInList/UpdateCheckInlistHandler.php @@ -1,11 +1,11 @@ updateCheckInlistService->updateCheckInlist( checkInList: $checkInList, - ticketIds: $data->ticketIds + productIds: $data->productIds ); } } diff --git a/backend/app/Services/Handlers/Event/CreateEventHandler.php b/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php similarity index 59% rename from backend/app/Services/Handlers/Event/CreateEventHandler.php rename to backend/app/Services/Application/Handlers/Event/CreateEventHandler.php index ae6e7b3f88..70fd94cd80 100644 --- a/backend/app/Services/Handlers/Event/CreateEventHandler.php +++ b/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php @@ -2,20 +2,24 @@ declare(strict_types=1); -namespace HiEvents\Services\Handlers\Event; +namespace HiEvents\Services\Application\Handlers\Event; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Exceptions\OrganizerNotFoundException; +use HiEvents\Services\Application\Handlers\Event\DTO\CreateEventDTO; use HiEvents\Services\Domain\Event\CreateEventService; use HiEvents\Services\Domain\Organizer\OrganizerFetchService; -use HiEvents\Services\Handlers\Event\DTO\CreateEventDTO; +use HiEvents\Services\Domain\ProductCategory\CreateProductCategoryService; +use Illuminate\Database\DatabaseManager; use Throwable; class CreateEventHandler { public function __construct( - private readonly CreateEventService $createEventService, - private readonly OrganizerFetchService $organizerFetchService + private readonly CreateEventService $createEventService, + private readonly OrganizerFetchService $organizerFetchService, + private readonly CreateProductCategoryService $createProductCategoryService, + private readonly DatabaseManager $databaseManager, ) { } @@ -25,6 +29,15 @@ public function __construct( * @throws Throwable */ public function handle(CreateEventDTO $eventData): EventDomainObject + { + return $this->databaseManager->transaction(fn() => $this->createEvent($eventData)); + } + + /** + * @throws OrganizerNotFoundException + * @throws Throwable + */ + private function createEvent(CreateEventDTO $eventData): EventDomainObject { $organizer = $this->organizerFetchService->fetchOrganizer( organizerId: $eventData->organizer_id, @@ -46,6 +59,10 @@ public function handle(CreateEventDTO $eventData): EventDomainObject ->setEventSettings($eventData->event_settings) ->setLocationDetails($eventData->location_details?->toArray()); - return $this->createEventService->createEvent($event); + $newEvent = $this->createEventService->createEvent($event); + + $this->createProductCategoryService->createDefaultProductCategory($newEvent); + + return $newEvent; } } diff --git a/backend/app/Services/Handlers/Event/CreateEventImageHandler.php b/backend/app/Services/Application/Handlers/Event/CreateEventImageHandler.php similarity index 82% rename from backend/app/Services/Handlers/Event/CreateEventImageHandler.php rename to backend/app/Services/Application/Handlers/Event/CreateEventImageHandler.php index 483548b8dd..fbfd832559 100644 --- a/backend/app/Services/Handlers/Event/CreateEventImageHandler.php +++ b/backend/app/Services/Application/Handlers/Event/CreateEventImageHandler.php @@ -1,10 +1,10 @@ accountId, title: $data->title, startDate: $data->startDate, - duplicateTickets: $data->duplicateTickets, + duplicateProducts: $data->duplicateProducts, duplicateQuestions: $data->duplicateQuestions, duplicateSettings: $data->duplicateSettings, duplicatePromoCodes: $data->duplicatePromoCodes, diff --git a/backend/app/Services/Handlers/Event/GetEventCheckInStatsHandler.php b/backend/app/Services/Application/Handlers/Event/GetEventCheckInStatsHandler.php similarity index 88% rename from backend/app/Services/Handlers/Event/GetEventCheckInStatsHandler.php rename to backend/app/Services/Application/Handlers/Event/GetEventCheckInStatsHandler.php index 388f63501e..f47aa22805 100644 --- a/backend/app/Services/Handlers/Event/GetEventCheckInStatsHandler.php +++ b/backend/app/Services/Application/Handlers/Event/GetEventCheckInStatsHandler.php @@ -1,6 +1,6 @@ eventRepository ->loadRelation( - new Relationship(TicketDomainObject::class, [ - new Relationship(TicketPriceDomainObject::class), - new Relationship(TaxAndFeesDomainObject::class) + new Relationship(ProductCategoryDomainObject::class, [ + new Relationship(ProductDomainObject::class, + nested: [ + new Relationship(ProductPriceDomainObject::class), + new Relationship(TaxAndFeesDomainObject::class), + ], + orderAndDirections: [ + new OrderAndDirection('order', 'asc'), + ] + ), ]) ) ->loadRelation(new Relationship(EventSettingDomainObject::class)) @@ -55,6 +64,9 @@ public function handle(GetPublicEventDTO $data): EventDomainObject $this->eventPageViewIncrementService->increment($data->eventId, $data->ipAddress); } - return $event->setTickets($this->ticketFilterService->filter($event->getTickets(), $promoCodeDomainObject)); + return $event->setProductCategories($this->productFilterService->filter( + productsCategories: $event->getProductCategories(), + promoCode: $promoCodeDomainObject + )); } } diff --git a/backend/app/Services/Application/Handlers/Event/GetPublicEventsHandler.php b/backend/app/Services/Application/Handlers/Event/GetPublicEventsHandler.php new file mode 100644 index 0000000000..a6ac75722a --- /dev/null +++ b/backend/app/Services/Application/Handlers/Event/GetPublicEventsHandler.php @@ -0,0 +1,28 @@ +eventRepository->findEvents( + where: [ + 'organizer_id' => $dto->organizerId, + 'status' => EventStatus::LIVE->name, + ], + params: $dto->queryParams + ); + } +} diff --git a/backend/app/Services/Handlers/Event/UpdateEventHandler.php b/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php similarity index 93% rename from backend/app/Services/Handlers/Event/UpdateEventHandler.php rename to backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php index f872725a3c..f1c4a1e3a0 100644 --- a/backend/app/Services/Handlers/Event/UpdateEventHandler.php +++ b/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php @@ -1,6 +1,6 @@ value], + offline_payment_instructions: null, + + // Invoice defaults + enable_invoicing: false, + invoice_label: __('Invoice'), + invoice_prefix: null, + invoice_start_number: 1, + require_billing_address: true, + organization_name: $organizer->getName(), + organization_address: null, + invoice_tax_details: null, + invoice_notes: null, + invoice_payment_terms_days: null, ); } } diff --git a/backend/app/Services/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php similarity index 64% rename from backend/app/Services/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php rename to backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php index 4a586f57a3..2e132142fd 100644 --- a/backend/app/Services/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php @@ -1,12 +1,12 @@ $eventSettingsDTO->settings['notify_organizer_of_new_orders'] ?? $existingSettings->getNotifyOrganizerOfNewOrders(), 'price_display_mode' => $eventSettingsDTO->settings['price_display_mode'] ?? $existingSettings->getPriceDisplayMode(), 'hide_getting_started_page' => $eventSettingsDTO->settings['hide_getting_started_page'] ?? $existingSettings->getHideGettingStartedPage(), + + // Payment settings + 'payment_providers' => $eventSettingsDTO->settings['payment_providers'] ?? $existingSettings->getPaymentProviders(), + 'offline_payment_instructions' => array_key_exists('offline_payment_instructions', $eventSettingsDTO->settings) + ? $eventSettingsDTO->settings['offline_payment_instructions'] + : $existingSettings->getOfflinePaymentInstructions(), + 'allow_orders_awaiting_offline_payment_to_check_in' => $eventSettingsDTO->settings['allow_orders_awaiting_offline_payment_to_check_in'] + ?? $existingSettings->getAllowOrdersAwaitingOfflinePaymentToCheckIn(), + + // Invoice settings + 'enable_invoicing' => $eventSettingsDTO->settings['enable_invoicing'] ?? $existingSettings->getEnableInvoicing(), + 'invoice_label' => array_key_exists('invoice_label', $eventSettingsDTO->settings) + ? $eventSettingsDTO->settings['invoice_label'] + : $existingSettings->getInvoiceLabel(), + 'invoice_prefix' => array_key_exists('invoice_prefix', $eventSettingsDTO->settings) + ? $eventSettingsDTO->settings['invoice_prefix'] + : $existingSettings->getInvoicePrefix(), + 'invoice_start_number' => $eventSettingsDTO->settings['invoice_start_number'] ?? $existingSettings->getInvoiceStartNumber(), + 'require_billing_address' => $eventSettingsDTO->settings['require_billing_address'] ?? $existingSettings->getRequireBillingAddress(), + 'organization_name' => array_key_exists('organization_name', $eventSettingsDTO->settings) + ? $eventSettingsDTO->settings['organization_name'] + : $existingSettings->getOrganizationName(), + 'organization_address' => array_key_exists('organization_address', $eventSettingsDTO->settings) + ? $eventSettingsDTO->settings['organization_address'] + : $existingSettings->getOrganizationAddress(), + 'invoice_tax_details' => array_key_exists('invoice_tax_details', $eventSettingsDTO->settings) + ? $eventSettingsDTO->settings['invoice_tax_details'] + : $existingSettings->getInvoiceTaxDetails(), + 'invoice_notes' => array_key_exists('invoice_notes', $eventSettingsDTO->settings) + ? $eventSettingsDTO->settings['invoice_notes'] + : $existingSettings->getInvoiceNotes(), + 'invoice_payment_terms_days' => array_key_exists('invoice_payment_terms_days', $eventSettingsDTO->settings) + ? $eventSettingsDTO->settings['invoice_payment_terms_days'] + : $existingSettings->getInvoicePaymentTermsDays() ]), ); } diff --git a/backend/app/Services/Handlers/EventSettings/UpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php similarity index 68% rename from backend/app/Services/Handlers/EventSettings/UpdateEventSettingsHandler.php rename to backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php index fb0a7693d9..f999b8312f 100644 --- a/backend/app/Services/Handlers/EventSettings/UpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php @@ -1,11 +1,11 @@ $settings->notify_organizer_of_new_orders, 'price_display_mode' => $settings->price_display_mode->name, 'hide_getting_started_page' => $settings->hide_getting_started_page, + + // Payment settings + 'payment_providers' => $settings->payment_providers, + 'offline_payment_instructions' => $settings->offline_payment_instructions + ?? $this->purifier->purify($settings->offline_payment_instructions), + 'allow_orders_awaiting_offline_payment_to_check_in' => $settings->allow_orders_awaiting_offline_payment_to_check_in, + + // Invoice settings + 'enable_invoicing' => $settings->enable_invoicing, + 'invoice_label' => trim($settings->invoice_label), + 'invoice_prefix' => trim($settings->invoice_prefix), + 'invoice_start_number' => $settings->invoice_start_number, + 'require_billing_address' => $settings->require_billing_address, + 'organization_name' => trim($settings->organization_name), + 'organization_address' => $this->purifier->purify($settings->organization_address), + 'invoice_tax_details' => $this->purifier->purify($settings->invoice_tax_details), + 'invoice_notes' => $this->purifier->purify($settings->invoice_notes), + 'invoice_payment_terms_days' => $settings->invoice_payment_terms_days, ], where: [ 'event_id' => $settings->event_id, diff --git a/backend/app/Services/Handlers/Message/DTO/SendMessageDTO.php b/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php similarity index 81% rename from backend/app/Services/Handlers/Message/DTO/SendMessageDTO.php rename to backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php index 72619fe946..3296a0bd77 100644 --- a/backend/app/Services/Handlers/Message/DTO/SendMessageDTO.php +++ b/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php @@ -1,6 +1,6 @@ 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. ' . + 'Please contact us at :email', [ + 'email' => $this->config->get('app.platform_support_email'), + ]) + ); + } + $message = $this->messageRepository->create([ 'event_id' => $messageData->event_id, 'subject' => $messageData->subject, @@ -48,10 +59,15 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject 'type' => $messageData->type->name, 'order_id' => $this->getOrderId($messageData), 'attendee_ids' => $this->getAttendeeIds($messageData)->toArray(), - 'ticket_ids' => $this->getTicketIds($messageData)->toArray(), + 'product_ids' => $this->getProductIds($messageData)->toArray(), 'sent_at' => Carbon::now()->toDateTimeString(), 'sent_by_user_id' => $messageData->sent_by_user_id, 'status' => MessageStatus::PROCESSING->name, + 'send_data' => [ + 'is_test' => $messageData->is_test, + 'send_copy_to_current_user' => $messageData->send_copy_to_current_user, + 'order_statuses' => $messageData->order_statuses, + ], ]); $updatedData = SendMessageDTO::fromArray([ @@ -63,10 +79,11 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject 'is_test' => $messageData->is_test, 'order_id' => $message->getOrderId(), 'attendee_ids' => $message->getAttendeeIds(), - 'ticket_ids' => $message->getTicketIds(), + 'product_ids' => $message->getProductIds(), 'send_copy_to_current_user' => $messageData->send_copy_to_current_user, 'sent_by_user_id' => $messageData->sent_by_user_id, 'account_id' => $messageData->account_id, + 'order_statuses' => $messageData->order_statuses, ]); SendMessagesJob::dispatch($updatedData); @@ -90,18 +107,18 @@ private function getAttendeeIds(SendMessageDTO $messageData): Collection } - private function getTicketIds(SendMessageDTO $messageData): Collection + private function getProductIds(SendMessageDTO $messageData): Collection { - $tickets = $this->ticketRepository->findWhereIn( + $products = $this->productRepository->findWhereIn( field: 'id', - values: $messageData->ticket_ids, + values: $messageData->product_ids, additionalWhere: [ 'event_id' => $messageData->event_id, ], columns: ['id'] ); - return $tickets->map(fn($attendee) => $attendee->getId()); + return $products->map(fn($attendee) => $attendee->getId()); } private function getOrderId(SendMessageDTO $messageData): ?int diff --git a/backend/app/Services/Application/Handlers/Order/CancelOrderHandler.php b/backend/app/Services/Application/Handlers/Order/CancelOrderHandler.php new file mode 100644 index 0000000000..0b30fb8ff5 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Order/CancelOrderHandler.php @@ -0,0 +1,53 @@ +databaseManager->transaction(function () use ($cancelOrderDTO) { + $order = $this->orderRepository + ->findFirstWhere([ + OrderDomainObjectAbstract::EVENT_ID => $cancelOrderDTO->eventId, + OrderDomainObjectAbstract::ID => $cancelOrderDTO->orderId, + ]); + + if (!$order) { + throw new ResourceNotFoundException(__('Order not found')); + } + + if ($order->isOrderCancelled()) { + throw new ResourceConflictException(__('Order already cancelled')); + } + + $this->orderCancelService->cancelOrder($order); + + return $this->orderRepository->findById($order->getId()); + }); + } +} diff --git a/backend/app/Services/Handlers/Order/CompleteOrderHandler.php b/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php similarity index 50% rename from backend/app/Services/Handlers/Order/CompleteOrderHandler.php rename to backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php index 0d9751cfc9..b998d84d52 100644 --- a/backend/app/Services/Handlers/Order/CompleteOrderHandler.php +++ b/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php @@ -2,35 +2,39 @@ declare(strict_types=1); -namespace HiEvents\Services\Handlers\Order; +namespace HiEvents\Services\Application\Handlers\Order; use Carbon\Carbon; use Exception; use HiEvents\DomainObjects\AttendeeDomainObject; +use HiEvents\DomainObjects\Enums\ProductType; +use HiEvents\DomainObjects\Enums\WebhookEventType; use HiEvents\DomainObjects\Generated\AttendeeDomainObjectAbstract; use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract; -use HiEvents\DomainObjects\Generated\TicketPriceDomainObjectAbstract; +use HiEvents\DomainObjects\Generated\ProductPriceDomainObjectAbstract; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; +use HiEvents\DomainObjects\ProductDomainObject; +use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\DomainObjects\Status\AttendeeStatus; use HiEvents\DomainObjects\Status\OrderPaymentStatus; use HiEvents\DomainObjects\Status\OrderStatus; -use HiEvents\DomainObjects\TicketDomainObject; -use HiEvents\DomainObjects\TicketPriceDomainObject; use HiEvents\Events\OrderStatusChangedEvent; use HiEvents\Exceptions\ResourceConflictException; use HiEvents\Helper\IdHelper; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; +use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface; use HiEvents\Repository\Interfaces\QuestionAnswerRepositoryInterface; -use HiEvents\Repository\Interfaces\TicketPriceRepositoryInterface; +use HiEvents\Services\Application\Handlers\Order\DTO\CompleteOrderDTO; +use HiEvents\Services\Application\Handlers\Order\DTO\CompleteOrderOrderDTO; +use HiEvents\Services\Application\Handlers\Order\DTO\CompleteOrderProductDataDTO; +use HiEvents\Services\Application\Handlers\Order\DTO\CreatedProductDataDTO; +use HiEvents\Services\Application\Handlers\Order\DTO\OrderQuestionsDTO; use HiEvents\Services\Domain\Payment\Stripe\EventHandlers\PaymentIntentSucceededHandler; -use HiEvents\Services\Domain\Ticket\TicketQuantityUpdateService; -use HiEvents\Services\Handlers\Order\DTO\CompleteOrderAttendeeDTO; -use HiEvents\Services\Handlers\Order\DTO\CompleteOrderDTO; -use HiEvents\Services\Handlers\Order\DTO\CompleteOrderOrderDTO; -use HiEvents\Services\Handlers\Order\DTO\OrderQuestionsDTO; +use HiEvents\Services\Domain\Product\ProductQuantityUpdateService; +use HiEvents\Services\Infrastructure\Webhook\WebhookDispatchService; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use RuntimeException; @@ -45,8 +49,9 @@ public function __construct( private readonly OrderRepositoryInterface $orderRepository, private readonly AttendeeRepositoryInterface $attendeeRepository, private readonly QuestionAnswerRepositoryInterface $questionAnswersRepository, - private readonly TicketQuantityUpdateService $ticketQuantityUpdateService, - private readonly TicketPriceRepositoryInterface $ticketPriceRepository, + private readonly ProductQuantityUpdateService $productQuantityUpdateService, + private readonly ProductPriceRepositoryInterface $productPriceRepository, + private readonly WebhookDispatchService $webhookDispatchService, ) { } @@ -63,72 +68,101 @@ public function handle(string $orderShortId, CompleteOrderDTO $orderData): Order $updatedOrder = $this->updateOrder($order, $orderDTO); - $this->createAttendees($orderData->attendees, $order); + $this->createAttendees($orderData->products, $order); if ($orderData->order->questions) { $this->createOrderQuestions($orderDTO->questions, $order); } /** - * If there's no payment required, immediately update the ticket quantities, otherwise handle + * If there's no payment required, immediately update the product quantities, otherwise handle * this in the PaymentIntentEventHandlerService * * @see PaymentIntentSucceededHandler */ if (!$order->isPaymentRequired()) { - $this->ticketQuantityUpdateService->updateQuantitiesFromOrder($updatedOrder); + $this->productQuantityUpdateService->updateQuantitiesFromOrder($updatedOrder); } OrderStatusChangedEvent::dispatch($updatedOrder); + if ($updatedOrder->isOrderCompleted()) { + $this->webhookDispatchService->queueOrderWebhook( + eventType: WebhookEventType::ORDER_CREATED, + orderId: $updatedOrder->getId(), + ); + } + return $updatedOrder; }); } /** + * @param Collection $orderProducts * @throws Exception */ - private function createAttendees(Collection $attendees, OrderDomainObject $order): void + private function createAttendees(Collection $orderProducts, OrderDomainObject $order): void { $inserts = []; + $createdProductData = collect(); - $ticketsPrices = $this->ticketPriceRepository->findWhereIn( - field: TicketPriceDomainObjectAbstract::ID, - values: $attendees->pluck('ticket_price_id')->toArray(), + $productsPrices = $this->productPriceRepository->findWhereIn( + field: ProductPriceDomainObjectAbstract::ID, + values: $orderProducts->pluck('product_price_id')->toArray(), ); - $this->validateTicketPriceIdsMatchOrder($order, $ticketsPrices); - $this->validateAttendees($order, $attendees); + $this->validateProductPriceIdsMatchOrder($order, $productsPrices); + $this->validateTicketProductsCount($order, $orderProducts); + + 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()); - foreach ($attendees as $attendee) { - $ticketId = $ticketsPrices->first( - fn(TicketPriceDomainObject $ticketPrice) => $ticketPrice->getId() === $attendee->ticket_price_id) - ->getTicketId(); + // 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, + shortId: null, + )); + + continue; + } + + $shortId = IdHelper::shortId(IdHelper::ATTENDEE_PREFIX); $inserts[] = [ AttendeeDomainObjectAbstract::EVENT_ID => $order->getEventId(), - AttendeeDomainObjectAbstract::TICKET_ID => $ticketId, - AttendeeDomainObjectAbstract::TICKET_PRICE_ID => $attendee->ticket_price_id, - AttendeeDomainObjectAbstract::STATUS => AttendeeStatus::ACTIVE->name, + AttendeeDomainObjectAbstract::PRODUCT_ID => $productId, + AttendeeDomainObjectAbstract::PRODUCT_PRICE_ID => $attendee->product_price_id, + AttendeeDomainObjectAbstract::STATUS => $order->isPaymentRequired() + ? AttendeeStatus::AWAITING_PAYMENT->name + : AttendeeStatus::ACTIVE->name, AttendeeDomainObjectAbstract::EMAIL => $attendee->email, AttendeeDomainObjectAbstract::FIRST_NAME => $attendee->first_name, AttendeeDomainObjectAbstract::LAST_NAME => $attendee->last_name, AttendeeDomainObjectAbstract::ORDER_ID => $order->getId(), AttendeeDomainObjectAbstract::PUBLIC_ID => IdHelper::publicId(IdHelper::ATTENDEE_PREFIX), - AttendeeDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::ATTENDEE_PREFIX), + AttendeeDomainObjectAbstract::SHORT_ID => $shortId, AttendeeDomainObjectAbstract::LOCALE => $order->getLocale(), ]; + + $createdProductData->push(new CreatedProductDataDTO( + productRequestData: $attendee, + shortId: $shortId, + )); } if (!$this->attendeeRepository->insert($inserts)) { throw new RuntimeException(__('Failed to create attendee')); } - $insertedAttendees = $this->attendeeRepository->findWhere([ - AttendeeDomainObjectAbstract::ORDER_ID => $order->getId() - ]); - - $this->createAttendeeQuestions($attendees, $insertedAttendees, $order, $ticketsPrices); + $this->createProductQuestions( + createdAttendees: $createdProductData, + order: $order, + productPrices: $productsPrices, + ); } private function createOrderQuestions(Collection $questions, OrderDomainObject $order): void @@ -145,32 +179,39 @@ private function createOrderQuestions(Collection $questions, OrderDomainObject $ }); } - private function createAttendeeQuestions( - Collection $attendees, - Collection $insertedAttendees, + /** + * @param Collection $createdAttendees + * @param Collection $productPrices + * @throws ResourceConflictException|Exception + */ + private function createProductQuestions( + Collection $createdAttendees, OrderDomainObject $order, - Collection $ticketPrices, + Collection $productPrices ): void { - $insertedIds = []; - /** @var CompleteOrderAttendeeDTO $attendee */ - foreach ($attendees as $attendee) { - $ticketId = $ticketPrices->first( - fn(TicketPriceDomainObject $ticketPrice) => $ticketPrice->getId() === $attendee->ticket_price_id) - ->getTicketId(); - - $attendeeIterator = $insertedAttendees->filter( - fn(AttendeeDomainObject $insertedAttendee) => $insertedAttendee->getTicketId() === $ticketId - && !in_array($insertedAttendee->getId(), $insertedIds, true) - )->getIterator(); - - if ($attendee->questions === null) { + $newAttendees = $this->attendeeRepository->findWhereIn( + field: AttendeeDomainObjectAbstract::SHORT_ID, + values: $createdAttendees->pluck('shortId')->toArray(), + ); + + foreach ($createdAttendees as $createdAttendee) { + $productRequestData = $createdAttendee->productRequestData; + + if ($productRequestData->questions === null) { continue; } - foreach ($attendee->questions as $question) { - $attendeeId = $attendeeIterator->current()->getId(); + $productId = $productPrices->first( + fn(ProductPriceDomainObject $productPrice) => $productPrice->getId() === $productRequestData->product_price_id + )->getProductId(); + + // This will be null for non-ticket products + $insertedAttendee = $newAttendees->first( + fn(AttendeeDomainObject $attendee) => $attendee->getShortId() === $createdAttendee->shortId, + ); + foreach ($productRequestData->questions as $question) { if (empty($question->response)) { continue; } @@ -179,11 +220,9 @@ private function createAttendeeQuestions( 'question_id' => $question->question_id, 'answer' => $question->response['answer'] ?? $question->response, 'order_id' => $order->getId(), - 'ticket_id' => $ticketId, - 'attendee_id' => $attendeeId + 'product_id' => $productId, + 'attendee_id' => $insertedAttendee?->getId(), ]); - - $insertedIds[] = $attendeeId; } } } @@ -215,7 +254,7 @@ private function getOrder(string $orderShortId): OrderDomainObject ->loadRelation( new Relationship( domainObject: OrderItemDomainObject::class, - nested: [new Relationship(TicketDomainObject::class, name: 'ticket')] + nested: [new Relationship(ProductDomainObject::class, name: 'product')] )) ->findByShortId($orderShortId); @@ -235,6 +274,7 @@ private function updateOrder(OrderDomainObject $order, CompleteOrderOrderDTO $or ->updateFromArray( $order->getId(), [ + OrderDomainObjectAbstract::ADDRESS => $orderDTO->address, OrderDomainObjectAbstract::FIRST_NAME => $orderDTO->first_name, OrderDomainObjectAbstract::LAST_NAME => $orderDTO->last_name, OrderDomainObjectAbstract::EMAIL => $orderDTO->email, @@ -249,33 +289,49 @@ private function updateOrder(OrderDomainObject $order, CompleteOrderOrderDTO $or } /** - * Check if the passed ticket price IDs match what exist in the order_items table + * Check if the passed product price IDs match what exist in the order_items table * * @throws ResourceConflictException */ - private function validateTicketPriceIdsMatchOrder(OrderDomainObject $order, Collection $ticketsPrices): void + private function validateProductPriceIdsMatchOrder(OrderDomainObject $order, Collection $productsPrices): void { - $orderTicketPriceIds = $order->getOrderItems() - ?->map(fn(OrderItemDomainObject $orderItem) => $orderItem->getTicketPriceId())->toArray(); + $orderProductPriceIds = $order->getOrderItems() + ?->map(fn(OrderItemDomainObject $orderItem) => $orderItem->getProductPriceId())->toArray(); - $ticketsPricesIds = $ticketsPrices->map(fn(TicketPriceDomainObject $ticketPrice) => $ticketPrice->getId()); + $productsPricesIds = $productsPrices->map(fn(ProductPriceDomainObject $productPrice) => $productPrice->getId()); - if ($ticketsPricesIds->diff($orderTicketPriceIds)->isNotEmpty()) { - throw new ResourceConflictException(__('There is an unexpected ticket price ID in the order')); + if ($productsPricesIds->diff($orderProductPriceIds)->isNotEmpty()) { + throw new ResourceConflictException(__('There is an unexpected product price ID in the order')); } } /** * @throws ResourceConflictException */ - private function validateAttendees(OrderDomainObject $order, Collection $attendees): void + private function validateTicketProductsCount(OrderDomainObject $order, Collection $attendees): void { - $orderAttendeeCount = $order->getOrderItems()->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()); - - if ($orderAttendeeCount !== $attendees->count()) { + $orderAttendeeCount = $order->getOrderItems() + ?->filter(fn(OrderItemDomainObject $orderItem) => $orderItem->getProductType() === ProductType::TICKET->name) + ?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()); + + $ticketAttendeeCount = $attendees + ->filter( + fn(CompleteOrderProductDataDTO $attendee) => $this->getProductTypeFromPriceId( + $attendee->product_price_id, + $order->getOrderItems() + ) === ProductType::TICKET->name) + ->count(); + + if ($orderAttendeeCount !== $ticketAttendeeCount) { throw new ResourceConflictException( __('The number of attendees does not match the number of tickets in the order') ); } } + + private function getProductTypeFromPriceId(int $priceId, Collection $orderItems): string + { + return $orderItems->first(fn(OrderItemDomainObject $orderItem) => $orderItem->getProductPriceId() === $priceId) + ->getProductType(); + } } diff --git a/backend/app/Services/Handlers/Order/CreateOrderHandler.php b/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php similarity index 94% rename from backend/app/Services/Handlers/Order/CreateOrderHandler.php rename to backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php index ba63055e7e..7f578c4639 100644 --- a/backend/app/Services/Handlers/Order/CreateOrderHandler.php +++ b/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HiEvents\Services\Handlers\Order; +namespace HiEvents\Services\Application\Handlers\Order; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; @@ -12,9 +12,9 @@ use HiEvents\DomainObjects\Status\EventStatus; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; +use HiEvents\Services\Application\Handlers\Order\DTO\CreateOrderPublicDTO; use HiEvents\Services\Domain\Order\OrderItemProcessingService; use HiEvents\Services\Domain\Order\OrderManagementService; -use HiEvents\Services\Handlers\Order\DTO\CreateOrderPublicDTO; use Illuminate\Database\DatabaseManager; use Illuminate\Validation\UnauthorizedException; use Throwable; @@ -64,7 +64,7 @@ public function handle( $orderItems = $this->orderItemProcessingService->process( order: $order, - ticketsOrderDetails: $createOrderPublicDTO->tickets, + productsOrderDetails: $createOrderPublicDTO->products, event: $event, promoCode: $promoCode, ); diff --git a/backend/app/Services/Handlers/Order/DTO/CancelOrderDTO.php b/backend/app/Services/Application/Handlers/Order/DTO/CancelOrderDTO.php similarity index 76% rename from backend/app/Services/Handlers/Order/DTO/CancelOrderDTO.php rename to backend/app/Services/Application/Handlers/Order/DTO/CancelOrderDTO.php index 015a5199cb..7f6069fbbc 100644 --- a/backend/app/Services/Handlers/Order/DTO/CancelOrderDTO.php +++ b/backend/app/Services/Application/Handlers/Order/DTO/CancelOrderDTO.php @@ -1,6 +1,6 @@ $attendees + * @param Collection $products */ public function __construct( public CompleteOrderOrderDTO $order, - #[CollectionOf(CompleteOrderAttendeeDTO::class)] - public Collection $attendees + #[CollectionOf(CompleteOrderProductDataDTO::class)] + public Collection $products ) { } diff --git a/backend/app/Services/Handlers/Order/DTO/CompleteOrderOrderDTO.php b/backend/app/Services/Application/Handlers/Order/DTO/CompleteOrderOrderDTO.php similarity index 92% rename from backend/app/Services/Handlers/Order/DTO/CompleteOrderOrderDTO.php rename to backend/app/Services/Application/Handlers/Order/DTO/CompleteOrderOrderDTO.php index 97793638a3..91c0f97aeb 100644 --- a/backend/app/Services/Handlers/Order/DTO/CompleteOrderOrderDTO.php +++ b/backend/app/Services/Application/Handlers/Order/DTO/CompleteOrderOrderDTO.php @@ -1,6 +1,6 @@ first_name !== null + && $this->last_name !== null + && $this->email !== null; + } +} diff --git a/backend/app/Services/Handlers/Order/DTO/CreateOrderPublicDTO.php b/backend/app/Services/Application/Handlers/Order/DTO/CreateOrderPublicDTO.php similarity index 73% rename from backend/app/Services/Handlers/Order/DTO/CreateOrderPublicDTO.php rename to backend/app/Services/Application/Handlers/Order/DTO/CreateOrderPublicDTO.php index 17bf413dbb..820513bca9 100644 --- a/backend/app/Services/Handlers/Order/DTO/CreateOrderPublicDTO.php +++ b/backend/app/Services/Application/Handlers/Order/DTO/CreateOrderPublicDTO.php @@ -1,6 +1,6 @@ + * @var Collection */ - public readonly Collection $tickets, + public readonly Collection $products, public readonly bool $is_user_authenticated, public readonly string $session_identifier, public readonly ?string $order_locale = null, diff --git a/backend/app/Services/Application/Handlers/Order/DTO/CreatedProductDataDTO.php b/backend/app/Services/Application/Handlers/Order/DTO/CreatedProductDataDTO.php new file mode 100644 index 0000000000..fc9573e119 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Order/DTO/CreatedProductDataDTO.php @@ -0,0 +1,15 @@ +logger->info(__('Editing order with ID: :id', [ + 'id' => $dto->id, + ])); + + return $this->editOrderService->editOrder( + id: $dto->id, + first_name: $dto->first_name, + last_name: $dto->last_name, + email: $dto->email, + notes: $dto->notes + ); + } +} diff --git a/backend/app/Services/Application/Handlers/Order/GetOrderInvoiceHandler.php b/backend/app/Services/Application/Handlers/Order/GetOrderInvoiceHandler.php new file mode 100644 index 0000000000..bf4c8bfe97 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Order/GetOrderInvoiceHandler.php @@ -0,0 +1,24 @@ +generateOrderInvoicePDFService->generatePdfFromOrderId( + orderId: $command->orderId, + eventId: $command->eventId, + ); + } +} diff --git a/backend/app/Services/Handlers/Order/GetOrderPublicHandler.php b/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php similarity index 70% rename from backend/app/Services/Handlers/Order/GetOrderPublicHandler.php rename to backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php index 214257aab2..ddda2fc502 100644 --- a/backend/app/Services/Handlers/Order/GetOrderPublicHandler.php +++ b/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php @@ -1,21 +1,25 @@ loadRelation(new Relationship(domainObject: InvoiceDomainObject::class)) ->loadRelation(new Relationship( domainObject: OrderItemDomainObject::class, )); @@ -79,6 +84,13 @@ private function getOrderDomainObject(GetOrderPublicDTO $getOrderData): ?OrderDo nested: [ new Relationship( domainObject: EventSettingDomainObject::class, + ), + new Relationship( + domainObject: OrganizerDomainObject::class, + name: OrganizerDomainObjectAbstract::SINGULAR_NAME, + ), + new Relationship( + domainObject: ImageDomainObject::class, ) ], name: EventDomainObjectAbstract::SINGULAR_NAME diff --git a/backend/app/Services/Application/Handlers/Order/MarkOrderAsPaidHandler.php b/backend/app/Services/Application/Handlers/Order/MarkOrderAsPaidHandler.php new file mode 100644 index 0000000000..98aa18d7d5 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Order/MarkOrderAsPaidHandler.php @@ -0,0 +1,36 @@ +logger->info(__('Marking order as paid'), [ + 'orderId' => $dto->orderId, + 'eventId' => $dto->eventId, + ]); + + return $this->markOrderAsPaidService->markOrderAsPaid( + $dto->orderId, + $dto->eventId, + ); + } +} diff --git a/backend/app/Services/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php similarity index 83% rename from backend/app/Services/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php rename to backend/app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php index 30423a3eff..f83dccee14 100644 --- a/backend/app/Services/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php +++ b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php @@ -1,12 +1,13 @@ accountRepository->findByEventId($order->getEventId()); + $account = $this->accountRepository + ->loadRelation(new Relationship( + domainObject: AccountConfigurationDomainObject::class, + name: 'configuration', + )) + ->findByEventId($order->getEventId()); // If we already have a Stripe session then re-fetch the client secret if ($order->getStripePayment() !== null) { @@ -76,6 +88,7 @@ public function handle(string $orderShortId): CreatePaymentIntentResponseDTO StripePaymentDomainObjectAbstract::ORDER_ID => $order->getId(), StripePaymentDomainObjectAbstract::PAYMENT_INTENT_ID => $paymentIntent->paymentIntentId, StripePaymentDomainObjectAbstract::CONNECTED_ACCOUNT_ID => $account->getStripeAccountId(), + StripePaymentDomainObjectAbstract::APPLICATION_FEE => $paymentIntent->applicationFeeAmount, ]); return $paymentIntent; diff --git a/backend/app/Services/Handlers/Order/Payment/Stripe/DTO/StripePaymentIntentPublicDTO.php b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/DTO/StripePaymentIntentPublicDTO.php similarity index 77% rename from backend/app/Services/Handlers/Order/Payment/Stripe/DTO/StripePaymentIntentPublicDTO.php rename to backend/app/Services/Application/Handlers/Order/Payment/Stripe/DTO/StripePaymentIntentPublicDTO.php index 1795631866..ae295d5199 100644 --- a/backend/app/Services/Handlers/Order/Payment/Stripe/DTO/StripePaymentIntentPublicDTO.php +++ b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/DTO/StripePaymentIntentPublicDTO.php @@ -1,6 +1,6 @@ type, self::$validEvents, true)) { + $this->logger->debug(__('Received a :event Stripe event, which has no handler', [ + 'event' => $event->type, + ]), [ + 'event_id' => $event->id, + 'event_type' => $event->type, + ]); + + return; + } + + if ($this->hasEventBeenHandled($event)) { + $this->logger->debug('Stripe event already handled', [ + 'event_id' => $event->id, + 'type' => $event->type, + 'data' => $event->data->object->toArray(), + ]); + + return; + } + $this->logger->debug('Stripe event received', $event->data->object->toArray()); switch ($event->type) { @@ -57,9 +87,9 @@ public function handle(StripeWebhookDTO $webhookDTO): void case Event::ACCOUNT_UPDATED: $this->accountUpdateHandler->handleEvent($event->data->object); break; - default: - $this->logger->debug(sprintf('Unhandled Stripe webhook: %s', $event->type)); } + + $this->markEventAsHandled($event); } catch (CannotAcceptPaymentException $exception) { $this->logger->error( 'Cannot accept payment: ' . $exception->getMessage(), [ @@ -88,4 +118,18 @@ public function handle(StripeWebhookDTO $webhookDTO): void throw $exception; } } + + private function hasEventBeenHandled(Event $event): bool + { + return $this->cache->has('stripe_event_' . $event->id); + } + + private function markEventAsHandled(Event $event): void + { + $this->logger->info('Marking Stripe event as handled', [ + 'event_id' => $event->id, + 'type' => $event->type, + ]); + $this->cache->put('stripe_event_' . $event->id, true, now()->addMinutes(60)); + } } diff --git a/backend/app/Services/Handlers/Order/Payment/Stripe/RefundOrderHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/RefundOrderHandler.php similarity index 97% rename from backend/app/Services/Handlers/Order/Payment/Stripe/RefundOrderHandler.php rename to backend/app/Services/Application/Handlers/Order/Payment/Stripe/RefundOrderHandler.php index 5f6dfd35dd..d2ac92d105 100644 --- a/backend/app/Services/Handlers/Order/Payment/Stripe/RefundOrderHandler.php +++ b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/RefundOrderHandler.php @@ -1,6 +1,6 @@ orderCancelService->cancelOrder($order); } - if ($refundOrderDTO->notify_buyer) { - $this->notifyBuyer($order, $event, $amount); - } - $this->refundService->refundPayment( amount: $amount, payment: $order->getStripePayment() ); + if ($refundOrderDTO->notify_buyer) { + $this->notifyBuyer($order, $event, $amount); + } + return $this->markOrderRefundPending($order); } } diff --git a/backend/app/Services/Application/Handlers/Order/Public/DownloadOrderInvoicePublicHandler.php b/backend/app/Services/Application/Handlers/Order/Public/DownloadOrderInvoicePublicHandler.php new file mode 100644 index 0000000000..36c2696714 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Order/Public/DownloadOrderInvoicePublicHandler.php @@ -0,0 +1,23 @@ +generateOrderInvoicePDFService->generatePdfFromOrderShortId( + orderShortId: $orderShortId, + eventId: $eventId, + ); + } +} diff --git a/backend/app/Services/Application/Handlers/Order/TransitionOrderToOfflinePaymentHandler.php b/backend/app/Services/Application/Handlers/Order/TransitionOrderToOfflinePaymentHandler.php new file mode 100644 index 0000000000..1b41da015c --- /dev/null +++ b/backend/app/Services/Application/Handlers/Order/TransitionOrderToOfflinePaymentHandler.php @@ -0,0 +1,104 @@ +databaseManager->transaction(function () use ($dto) { + /** @var OrderDomainObjectAbstract $order */ + $order = $this->orderRepository + ->loadRelation(OrderItemDomainObject::class) + ->findByShortId($dto->orderShortId); + + /** @var EventSettingDomainObject $eventSettings */ + $eventSettings = $this->eventSettingsRepository->findFirstWhere([ + 'event_id' => $order->getEventId(), + ]); + + $this->validateOfflinePayment($order, $eventSettings); + + $this->updateOrderStatuses($order->getId()); + + $this->productQuantityUpdateService->updateQuantitiesFromOrder($order); + + $order = $this->orderRepository + ->loadRelation(OrderItemDomainObject::class) + ->findById($order->getId()); + + event(new OrderStatusChangedEvent( + order: $order, + sendEmails: true, + createInvoice: $eventSettings->getEnableInvoicing(), + )); + + $this->webhookDispatchService->queueOrderWebhook( + eventType: WebhookEventType::ORDER_CREATED, + orderId: $order->getId(), + ); + + return $order; + }); + } + + private function updateOrderStatuses(int $orderId): void + { + $this->orderRepository + ->updateFromArray($orderId, [ + OrderDomainObjectAbstract::PAYMENT_STATUS => OrderPaymentStatus::AWAITING_OFFLINE_PAYMENT->name, + OrderDomainObjectAbstract::STATUS => OrderStatus::AWAITING_OFFLINE_PAYMENT->name, + OrderDomainObjectAbstract::PAYMENT_PROVIDER => PaymentProviders::OFFLINE->value, + ]); + } + + /** + * @throws ResourceConflictException + */ + public function validateOfflinePayment( + OrderDomainObject $order, + EventSettingDomainObject $settings, + ): void + { + if (!$order->isOrderReserved()) { + throw new ResourceConflictException(__('Order is not in the correct status to transition to offline payment')); + } + + if ($order->isReservedOrderExpired()) { + throw new ResourceConflictException(__('Order reservation has expired')); + } + + if (collect($settings->getPaymentProviders())->contains(PaymentProviders::OFFLINE->value) === false) { + throw new UnauthorizedException(__('Offline payments are not enabled for this event')); + } + } +} diff --git a/backend/app/Services/Handlers/Organizer/CreateOrganizerHandler.php b/backend/app/Services/Application/Handlers/Organizer/CreateOrganizerHandler.php similarity index 93% rename from backend/app/Services/Handlers/Organizer/CreateOrganizerHandler.php rename to backend/app/Services/Application/Handlers/Organizer/CreateOrganizerHandler.php index dd0e11e363..765dd9e386 100644 --- a/backend/app/Services/Handlers/Organizer/CreateOrganizerHandler.php +++ b/backend/app/Services/Application/Handlers/Organizer/CreateOrganizerHandler.php @@ -1,13 +1,13 @@ organizerRepository->findById($organizerId); + } +} diff --git a/backend/app/Services/Application/Handlers/Product/CreateProductHandler.php b/backend/app/Services/Application/Handlers/Product/CreateProductHandler.php new file mode 100644 index 0000000000..5635d64a54 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Product/CreateProductHandler.php @@ -0,0 +1,70 @@ +prices->map(fn(ProductPriceDTO $price) => ProductPriceDomainObject::hydrateFromArray([ + ProductPriceDomainObjectAbstract::PRICE => $productsData->type === ProductPriceType::FREE ? 0.00 : $price->price, + ProductPriceDomainObjectAbstract::LABEL => $price->label, + ProductPriceDomainObjectAbstract::SALE_START_DATE => $price->sale_start_date, + ProductPriceDomainObjectAbstract::SALE_END_DATE => $price->sale_end_date, + ProductPriceDomainObjectAbstract::INITIAL_QUANTITY_AVAILABLE => $price->initial_quantity_available, + ProductPriceDomainObjectAbstract::IS_HIDDEN => $price->is_hidden, + ])); + + $category = $this->getProductCategoryService->getCategory( + categoryId: $productsData->product_category_id, + eventId: $productsData->event_id + ); + + return $this->productCreateService->createProduct( + product: (new ProductDomainObject()) + ->setTitle($productsData->title) + ->setType($productsData->type->name) + ->setOrder($productsData->order) + ->setSaleStartDate($productsData->sale_start_date) + ->setSaleEndDate($productsData->sale_end_date) + ->setMaxPerOrder($productsData->max_per_order) + ->setDescription($productsData->description) + ->setMinPerOrder($productsData->min_per_order) + ->setIsHidden($productsData->is_hidden) + ->setStartCollapsed($productsData->start_collapsed) + ->setHideBeforeSaleStartDate($productsData->hide_before_sale_start_date) + ->setHideAfterSaleEndDate($productsData->hide_after_sale_end_date) + ->setHideWhenSoldOut($productsData->hide_when_sold_out) + ->setShowQuantityRemaining($productsData->show_quantity_remaining) + ->setIsHiddenWithoutPromoCode($productsData->is_hidden_without_promo_code) + ->setProductPrices($productPrices) + ->setEventId($productsData->event_id) + ->setProductType($productsData->product_type->name) + ->setProductCategoryId($category->getId()), + accountId: $productsData->account_id, + taxAndFeeIds: $productsData->tax_and_fee_ids, + ); + } +} diff --git a/backend/app/Services/Application/Handlers/Product/DTO/UpsertProductDTO.php b/backend/app/Services/Application/Handlers/Product/DTO/UpsertProductDTO.php new file mode 100644 index 0000000000..6fa9f9752e --- /dev/null +++ b/backend/app/Services/Application/Handlers/Product/DTO/UpsertProductDTO.php @@ -0,0 +1,45 @@ +deleteProductService->deleteProduct($productId, $eventId); + } +} diff --git a/backend/app/Services/Application/Handlers/Product/EditProductHandler.php b/backend/app/Services/Application/Handlers/Product/EditProductHandler.php new file mode 100644 index 0000000000..ae02ebe300 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Product/EditProductHandler.php @@ -0,0 +1,161 @@ +databaseManager->transaction(function () use ($productsData) { + $where = [ + 'event_id' => $productsData->event_id, + 'id' => $productsData->product_id, + ]; + + $product = $this->updateProduct($productsData, $where); + + $this->addTaxes($product, $productsData); + + $this->priceUpdateService->updatePrices( + $product, + $productsData, + $product->getProductPrices(), + $this->eventRepository->findById($productsData->event_id) + ); + + $this->webhookDispatchService->queueProductWebhook( + eventType: WebhookEventType::PRODUCT_UPDATED, + productId: $product->getId(), + ); + + return $this->productRepository + ->loadRelation(ProductPriceDomainObject::class) + ->findById($product->getId()); + }); + } + + /** + * @throws CannotChangeProductTypeException + */ + private function updateProduct(UpsertProductDTO $productsData, array $where): ProductDomainObject + { + $event = $this->eventRepository->findById($productsData->event_id); + + $this->validateChangeInProductType($productsData); + + $productCategory = $this->getProductCategoryService->getCategory( + $productsData->product_category_id, + $productsData->event_id, + ); + + $this->productRepository->updateWhere( + attributes: [ + 'title' => $productsData->title, + 'type' => $productsData->type->name, + 'order' => $this->productOrderingService->getOrderForNewProduct( + eventId: $productsData->event_id, + productCategoryId: $productCategory->getId(), + ), + 'sale_start_date' => $productsData->sale_start_date + ? DateHelper::convertToUTC($productsData->sale_start_date, $event->getTimezone()) + : null, + 'sale_end_date' => $productsData->sale_end_date + ? DateHelper::convertToUTC($productsData->sale_end_date, $event->getTimezone()) + : null, + 'max_per_order' => $productsData->max_per_order, + 'description' => $this->purifier->purify($productsData->description), + 'min_per_order' => $productsData->min_per_order, + 'is_hidden' => $productsData->is_hidden, + 'start_collapsed' => $productsData->start_collapsed, + 'hide_before_sale_start_date' => $productsData->hide_before_sale_start_date, + 'hide_after_sale_end_date' => $productsData->hide_after_sale_end_date, + 'hide_when_sold_out' => $productsData->hide_when_sold_out, + 'show_quantity_remaining' => $productsData->show_quantity_remaining, + 'is_hidden_without_promo_code' => $productsData->is_hidden_without_promo_code, + 'product_type' => $productsData->product_type->name, + 'product_category_id' => $productCategory->getId(), + ], + where: $where + ); + + return $this->productRepository + ->loadRelation(ProductPriceDomainObject::class) + ->findFirstWhere($where); + } + + /** + * @throws Exception + */ + private function addTaxes(ProductDomainObject $product, UpsertProductDTO $productsData): void + { + $this->taxAndProductAssociationService->addTaxesToProduct( + new TaxAndProductAssociateParams( + productId: $product->getId(), + accountId: $productsData->account_id, + taxAndFeeIds: $productsData->tax_and_fee_ids, + ) + ); + } + + /** + * @throws CannotChangeProductTypeException + * @todo - We should probably check reserved products here as well + */ + private function validateChangeInProductType(UpsertProductDTO $productsData): void + { + $product = $this->productRepository + ->loadRelation(ProductPriceDomainObject::class) + ->findById($productsData->product_id); + + $quantitySold = $product->getProductPrices() + ->sum(fn(ProductPriceDomainObject $price) => $price->getQuantitySold()); + + if ($product->getType() !== $productsData->type->name && $quantitySold > 0) { + throw new CannotChangeProductTypeException( + __('Product type cannot be changed as products have been registered for this type') + ); + } + } +} diff --git a/backend/app/Services/Application/Handlers/Product/GetProductsHandler.php b/backend/app/Services/Application/Handlers/Product/GetProductsHandler.php new file mode 100644 index 0000000000..73ae5c7460 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Product/GetProductsHandler.php @@ -0,0 +1,37 @@ +productRepository + ->loadRelation(ProductPriceDomainObject::class) + ->loadRelation(TaxAndFeesDomainObject::class) + ->findByEventId($eventId, $queryParamsDTO); + + $filteredProducts = $this->productFilterService->filter( + productsCategories: $productPaginator->getCollection(), + hideSoldOutProducts: false, + ); + + $productPaginator->setCollection($filteredProducts); + + return $productPaginator; + } +} diff --git a/backend/app/Services/Application/Handlers/Product/SortProductsHandler.php b/backend/app/Services/Application/Handlers/Product/SortProductsHandler.php new file mode 100644 index 0000000000..557bcaef23 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Product/SortProductsHandler.php @@ -0,0 +1,73 @@ +productCategoryRepository + ->loadRelation(ProductDomainObject::class) + ->findWhere(['event_id' => $eventId]); + + $existingCategoryIds = $categories->map(fn($category) => $category->getId())->toArray(); + $existingProductIds = $categories->flatMap(fn($category) => $category->products->map(fn($product) => $product->getId()))->toArray(); + + $orderedCategoryIds = collect($sortData)->pluck('product_category_id')->toArray(); + $orderedProductIds = collect($sortData) + ->flatMap(fn($category) => collect($category['sorted_products'])->pluck('id')) + ->toArray(); + + if (array_diff($existingCategoryIds, $orderedCategoryIds) || array_diff($orderedCategoryIds, $existingCategoryIds)) { + throw new ResourceConflictException( + __('The ordered category IDs must exactly match all categories for the event without missing or extra IDs.') + ); + } + + if (array_diff($existingProductIds, $orderedProductIds) || array_diff($orderedProductIds, $existingProductIds)) { + throw new ResourceConflictException( + __('The ordered product IDs must exactly match all products for the event without missing or extra IDs.') + ); + } + + $productUpdates = []; + $categoryUpdates = []; + + foreach ($sortData as $categoryIndex => $category) { + $categoryId = $category['product_category_id']; + $categoryUpdates[] = [ + 'id' => $categoryId, + 'order' => $categoryIndex + 1, + ]; + + foreach ($category['sorted_products'] as $productIndex => $product) { + $productUpdates[] = [ + 'id' => $product['id'], + 'order' => $productIndex + 1, + 'product_category_id' => $categoryId, + ]; + } + } + + $this->productRepository->bulkUpdateProductsAndCategories( + eventId: $eventId, + productUpdates: $productUpdates, + categoryUpdates: $categoryUpdates, + ); + } +} diff --git a/backend/app/Services/Application/Handlers/ProductCategory/CreateProductCategoryHandler.php b/backend/app/Services/Application/Handlers/ProductCategory/CreateProductCategoryHandler.php new file mode 100644 index 0000000000..30ffc4008c --- /dev/null +++ b/backend/app/Services/Application/Handlers/ProductCategory/CreateProductCategoryHandler.php @@ -0,0 +1,30 @@ +setName($dto->name); + $productCategory->setIsHidden($dto->is_hidden); + $productCategory->setEventId($dto->event_id); + $productCategory->setDescription($dto->description); + $productCategory->setNoProductsMessage( + $dto->no_products_message ?? __('There are no products available in this category' + )); + + return $this->productCategoryService->createCategory($productCategory); + } +} diff --git a/backend/app/Services/Application/Handlers/ProductCategory/DTO/UpsertProductCategoryDTO.php b/backend/app/Services/Application/Handlers/ProductCategory/DTO/UpsertProductCategoryDTO.php new file mode 100644 index 0000000000..01e2d8e195 --- /dev/null +++ b/backend/app/Services/Application/Handlers/ProductCategory/DTO/UpsertProductCategoryDTO.php @@ -0,0 +1,19 @@ +deleteProductCategoryService->deleteProductCategory($productCategoryId, $eventId); + } +} diff --git a/backend/app/Services/Application/Handlers/ProductCategory/EditProductCategoryHandler.php b/backend/app/Services/Application/Handlers/ProductCategory/EditProductCategoryHandler.php new file mode 100644 index 0000000000..105adde141 --- /dev/null +++ b/backend/app/Services/Application/Handlers/ProductCategory/EditProductCategoryHandler.php @@ -0,0 +1,34 @@ +productCategoryRepository->updateWhere( + attributes: [ + 'name' => $dto->name, + 'is_hidden' => $dto->is_hidden, + 'description' => $dto->description, + 'no_products_message' => $dto->no_products_message ?? __('There are no products available in this category'), + ], + where: [ + 'id' => $dto->product_category_id, + 'event_id' => $dto->event_id, + ], + ); + + return $this->productCategoryRepository->findById($dto->product_category_id); + } +} diff --git a/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoriesHandler.php b/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoriesHandler.php new file mode 100644 index 0000000000..ccd815bb94 --- /dev/null +++ b/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoriesHandler.php @@ -0,0 +1,49 @@ +productCategoryRepository + ->loadRelation(new Relationship( + domainObject: ProductDomainObject::class, + nested: [ + new Relationship(ProductPriceDomainObject::class), + new Relationship(TaxAndFeesDomainObject::class), + ], + orderAndDirections: [ + new OrderAndDirection( + order: ProductDomainObjectAbstract::ORDER, + ), + ], + )) + ->findWhere( + where: [ + 'event_id' => $eventId, + ], + orderAndDirections: [ + new OrderAndDirection( + order: ProductCategoryDomainObjectAbstract::ORDER, + ), + ], + ); + } +} diff --git a/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoryHandler.php b/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoryHandler.php new file mode 100644 index 0000000000..0df9769462 --- /dev/null +++ b/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoryHandler.php @@ -0,0 +1,44 @@ +productCategoryRepository + ->loadRelation(new Relationship( + domainObject: ProductDomainObject::class, + nested: [ + new Relationship(ProductPriceDomainObject::class), + new Relationship(TaxAndFeesDomainObject::class), + ], + orderAndDirections: [ + new OrderAndDirection( + order: ProductDomainObjectAbstract::ORDER, + ), + ], + )) + ->findFirstWhere( + where: [ + 'event_id' => $eventId, + 'id' => $productCategoryId, + ] + ); + } +} diff --git a/backend/app/Services/Handlers/PromoCode/CreatePromoCodeHandler.php b/backend/app/Services/Application/Handlers/PromoCode/CreatePromoCodeHandler.php similarity index 73% rename from backend/app/Services/Handlers/PromoCode/CreatePromoCodeHandler.php rename to backend/app/Services/Application/Handlers/PromoCode/CreatePromoCodeHandler.php index 84e954a393..37d3e9e821 100644 --- a/backend/app/Services/Handlers/PromoCode/CreatePromoCodeHandler.php +++ b/backend/app/Services/Application/Handlers/PromoCode/CreatePromoCodeHandler.php @@ -1,12 +1,12 @@ setDiscount($promoCodeDTO->discount) ->setExpiryDate($promoCodeDTO->expiry_date) ->setMaxAllowedUsages($promoCodeDTO->max_allowed_usages) - ->setApplicableTicketIds($promoCodeDTO->applicable_ticket_ids) + ->setApplicableProductIds($promoCodeDTO->applicable_product_ids) ); } } diff --git a/backend/app/Services/Handlers/PromoCode/DTO/DeletePromoCodeDTO.php b/backend/app/Services/Application/Handlers/PromoCode/DTO/DeletePromoCodeDTO.php similarity index 79% rename from backend/app/Services/Handlers/PromoCode/DTO/DeletePromoCodeDTO.php rename to backend/app/Services/Application/Handlers/PromoCode/DTO/DeletePromoCodeDTO.php index 72e02245aa..26768a3b71 100644 --- a/backend/app/Services/Handlers/PromoCode/DTO/DeletePromoCodeDTO.php +++ b/backend/app/Services/Application/Handlers/PromoCode/DTO/DeletePromoCodeDTO.php @@ -1,6 +1,6 @@ eventTicketValidationService->validateTicketIds( - ticketIds: $promoCodeDTO->applicable_ticket_ids, + $this->eventProductValidationService->validateProductIds( + productIds: $promoCodeDTO->applicable_product_ids, eventId: $promoCodeDTO->event_id ); @@ -57,7 +57,7 @@ public function handle(int $promoCodeId, UpsertPromoCodeDTO $promoCodeDTO): Prom ? DateHelper::convertToUTC($promoCodeDTO->expiry_date, $event->getTimezone()) : null, PromoCodeDomainObjectAbstract::MAX_ALLOWED_USAGES => $promoCodeDTO->max_allowed_usages, - PromoCodeDomainObjectAbstract::APPLICABLE_TICKET_IDS => $promoCodeDTO->applicable_ticket_ids, + PromoCodeDomainObjectAbstract::APPLICABLE_PRODUCT_IDS => $promoCodeDTO->applicable_product_ids, ]); } } diff --git a/backend/app/Services/Handlers/Question/CreateQuestionHandler.php b/backend/app/Services/Application/Handlers/Question/CreateQuestionHandler.php similarity index 77% rename from backend/app/Services/Handlers/Question/CreateQuestionHandler.php rename to backend/app/Services/Application/Handlers/Question/CreateQuestionHandler.php index a8f641e768..9ecd720358 100644 --- a/backend/app/Services/Handlers/Question/CreateQuestionHandler.php +++ b/backend/app/Services/Application/Handlers/Question/CreateQuestionHandler.php @@ -1,18 +1,18 @@ createQuestionService->createQuestion( $question, - $createQuestionDTO->ticket_ids, + $createQuestionDTO->product_ids, ); } } diff --git a/backend/app/Services/Handlers/Question/DTO/UpsertQuestionDTO.php b/backend/app/Services/Application/Handlers/Question/DTO/UpsertQuestionDTO.php similarity index 84% rename from backend/app/Services/Handlers/Question/DTO/UpsertQuestionDTO.php rename to backend/app/Services/Application/Handlers/Question/DTO/UpsertQuestionDTO.php index c10345406e..e83869009c 100644 --- a/backend/app/Services/Handlers/Question/DTO/UpsertQuestionDTO.php +++ b/backend/app/Services/Application/Handlers/Question/DTO/UpsertQuestionDTO.php @@ -1,6 +1,6 @@ editQuestionService->editQuestion( question: $question, - ticketIds: $createQuestionDTO->ticket_ids, + productIds: $createQuestionDTO->product_ids, ); } } diff --git a/backend/app/Services/Handlers/Question/SortQuestionsHandler.php b/backend/app/Services/Application/Handlers/Question/SortQuestionsHandler.php similarity index 89% rename from backend/app/Services/Handlers/Question/SortQuestionsHandler.php rename to backend/app/Services/Application/Handlers/Question/SortQuestionsHandler.php index 379271f9ef..e0387bec71 100644 --- a/backend/app/Services/Handlers/Question/SortQuestionsHandler.php +++ b/backend/app/Services/Application/Handlers/Question/SortQuestionsHandler.php @@ -1,6 +1,6 @@ questionRepository->findWhere([ 'event_id' => $eventId, ]) - ->map(fn($ticket) => $ticket->getId()) + ->map(fn($product) => $product->getId()) ->toArray(); $extraInOrdered = array_diff($orderedQuestionIds, $questionIdResult); diff --git a/backend/app/Services/Application/Handlers/Reports/DTO/GetReportDTO.php b/backend/app/Services/Application/Handlers/Reports/DTO/GetReportDTO.php new file mode 100644 index 0000000000..295c9b4da9 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Reports/DTO/GetReportDTO.php @@ -0,0 +1,18 @@ +reportServiceFactory + ->create($reportData->reportType) + ->generateReport( + eventId: $reportData->eventId, + startDate: $reportData->startDate ? Carbon::parse($reportData->startDate) : null, + endDate: $reportData->endDate ? Carbon::parse($reportData->endDate) : null, + ); + } +} diff --git a/backend/app/Services/Handlers/TaxAndFee/CreateTaxOrFeeHandler.php b/backend/app/Services/Application/Handlers/TaxAndFee/CreateTaxOrFeeHandler.php similarity index 93% rename from backend/app/Services/Handlers/TaxAndFee/CreateTaxOrFeeHandler.php rename to backend/app/Services/Application/Handlers/TaxAndFee/CreateTaxOrFeeHandler.php index f8594b18d8..faaa849b69 100644 --- a/backend/app/Services/Handlers/TaxAndFee/CreateTaxOrFeeHandler.php +++ b/backend/app/Services/Application/Handlers/TaxAndFee/CreateTaxOrFeeHandler.php @@ -1,12 +1,12 @@ databaseManager->transaction(fn() => $this->createWebhook($upsertWebhookDTO)); + } + + private function createWebhook(CreateWebhookDTO $upsertWebhookDTO): WebhookDomainObject + { + $webhookDomainObject = (new WebhookDomainObject()) + ->setUrl($upsertWebhookDTO->url) + ->setEventTypes($upsertWebhookDTO->eventTypes) + ->setEventId($upsertWebhookDTO->eventId) + ->setUserId($upsertWebhookDTO->userId) + ->setAccountId($upsertWebhookDTO->accountId) + ->setStatus($upsertWebhookDTO->status->value); + + return $this->createWebhookService->createWebhook($webhookDomainObject); + } +} diff --git a/backend/app/Services/Application/Handlers/Webhook/DTO/CreateWebhookDTO.php b/backend/app/Services/Application/Handlers/Webhook/DTO/CreateWebhookDTO.php new file mode 100644 index 0000000000..2c834e336a --- /dev/null +++ b/backend/app/Services/Application/Handlers/Webhook/DTO/CreateWebhookDTO.php @@ -0,0 +1,20 @@ +databaseManager->transaction(function () use ($eventId, $webhookId) { + $webhook = $this->webhookRepository->findFirstWhere([ + 'id' => $webhookId, + 'event_id' => $eventId, + ]); + + if (!$webhook) { + throw new ResourceNotFoundException(__( + key: 'Webhook not found for ID: :webhookId and event ID: :eventId', + replace: [ + 'webhookId' => $webhookId, + 'eventId' => $eventId, + ] + )); + } + + $this->webhookRepository->deleteWhere([ + 'id' => $webhookId, + 'event_id' => $eventId, + ]); + + $this->webhookLogRepository + ->deleteOldLogs($webhookId, numberToKeep: 0); + }); + } +} diff --git a/backend/app/Services/Application/Handlers/Webhook/EditWebhookHandler.php b/backend/app/Services/Application/Handlers/Webhook/EditWebhookHandler.php new file mode 100644 index 0000000000..3f3c86efe8 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Webhook/EditWebhookHandler.php @@ -0,0 +1,49 @@ +databaseManager->transaction(function () use ($dto) { + /** @var WebhookDomainObject $webhook */ + $webhook = $this->webhookRepository->findFirstWhere([ + 'id' => $dto->webhookId, + 'event_id' => $dto->eventId, + ]); + + if (!$webhook) { + throw new ResourceNotFoundException(__( + key: 'Webhook not found for ID: :webhookId and event ID: :eventId', + replace: [ + 'webhookId' => $dto->webhookId, + 'eventId' => $dto->eventId, + ] + )); + } + + return $this->webhookRepository->updateFromArray( + id: $webhook->getId(), + attributes: [ + 'url' => $dto->url, + 'event_types' => $dto->eventTypes, + 'status' => $dto->status->value, + ] + ); + }); + } +} diff --git a/backend/app/Services/Application/Handlers/Webhook/GetWebhookHandler.php b/backend/app/Services/Application/Handlers/Webhook/GetWebhookHandler.php new file mode 100644 index 0000000000..8d4eafc0d9 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Webhook/GetWebhookHandler.php @@ -0,0 +1,25 @@ +webhookRepository->findFirstWhere( + where: [ + 'id' => $webhookId, + 'event_id' => $eventId, + ] + ); + } +} diff --git a/backend/app/Services/Application/Handlers/Webhook/GetWebhookLogsHandler.php b/backend/app/Services/Application/Handlers/Webhook/GetWebhookLogsHandler.php new file mode 100644 index 0000000000..e3e6a37828 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Webhook/GetWebhookLogsHandler.php @@ -0,0 +1,40 @@ +webhookRepository->findFirstWhere( + where: [ + 'id' => $webhookId, + 'event_id' => $eventId, + ] + ); + + if (!$webhook) { + throw new ResourceNotFoundException(__('Webhook not found')); + } + + return $this->webhookLogRepository + ->paginateWhere( + where: [ + 'webhook_id' => $webhook->getId(), + ], + limit: 10, + ); + } +} diff --git a/backend/app/Services/Application/Handlers/Webhook/GetWebhooksHandler.php b/backend/app/Services/Application/Handlers/Webhook/GetWebhooksHandler.php new file mode 100644 index 0000000000..1ae708df0a --- /dev/null +++ b/backend/app/Services/Application/Handlers/Webhook/GetWebhooksHandler.php @@ -0,0 +1,29 @@ +webhookRepository->findWhere( + where: [ + 'account_id' => $accountId, + 'event_id' => $eventId, + ], + orderAndDirections: [ + new OrderAndDirection('id', OrderAndDirection::DIRECTION_DESC), + ] + ); + } +} diff --git a/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php b/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php index 27da9f08ae..78286da8e9 100644 --- a/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php +++ b/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php @@ -5,19 +5,21 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; +use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Mail\Attendee\AttendeeTicketMail; use Illuminate\Contracts\Mail\Mailer; -readonly class SendAttendeeTicketService +class SendAttendeeTicketService { public function __construct( - private Mailer $mailer + private readonly Mailer $mailer ) { } public function send( + OrderDomainObject $order, AttendeeDomainObject $attendee, EventDomainObject $event, EventSettingDomainObject $eventSettings, @@ -28,6 +30,7 @@ public function send( ->to($attendee->getEmail()) ->locale($attendee->getLocale()) ->send(new AttendeeTicketMail( + order: $order, attendee: $attendee, event: $event, eventSettings: $eventSettings, diff --git a/backend/app/Services/Domain/CapacityAssignment/CapacityAssignmentTicketAssociationService.php b/backend/app/Services/Domain/CapacityAssignment/CapacityAssignmentProductAssociationService.php similarity index 50% rename from backend/app/Services/Domain/CapacityAssignment/CapacityAssignmentTicketAssociationService.php rename to backend/app/Services/Domain/CapacityAssignment/CapacityAssignmentProductAssociationService.php index c5a0f5a2f8..e7f1b68de7 100644 --- a/backend/app/Services/Domain/CapacityAssignment/CapacityAssignmentTicketAssociationService.php +++ b/backend/app/Services/Domain/CapacityAssignment/CapacityAssignmentProductAssociationService.php @@ -2,52 +2,52 @@ namespace HiEvents\Services\Domain\CapacityAssignment; -use HiEvents\Repository\Interfaces\TicketRepositoryInterface; +use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use Illuminate\Database\DatabaseManager; -class CapacityAssignmentTicketAssociationService +class CapacityAssignmentProductAssociationService { public function __construct( - private readonly TicketRepositoryInterface $ticketRepository, - public readonly DatabaseManager $databaseManager, + private readonly ProductRepositoryInterface $productRepository, + public readonly DatabaseManager $databaseManager, ) { } - public function addCapacityToTickets( + public function addCapacityToProducts( int $capacityAssignmentId, - ?array $ticketIds, + ?array $productIds, bool $removePreviousAssignments = true ): void { - $this->databaseManager->transaction(function () use ($capacityAssignmentId, $ticketIds, $removePreviousAssignments) { - $this->associateTicketsWithCapacityAssignment( + $this->databaseManager->transaction(function () use ($capacityAssignmentId, $productIds, $removePreviousAssignments) { + $this->associateProductsWithCapacityAssignment( capacityAssignmentId: $capacityAssignmentId, - ticketIds: $ticketIds, + productIds: $productIds, removePreviousAssignments: $removePreviousAssignments, ); }); } - private function associateTicketsWithCapacityAssignment( + private function associateProductsWithCapacityAssignment( int $capacityAssignmentId, - ?array $ticketIds, + ?array $productIds, bool $removePreviousAssignments = true ): void { - if (empty($ticketIds)) { + if (empty($productIds)) { return; } if ($removePreviousAssignments) { - $this->ticketRepository->removeCapacityAssignmentFromTickets( + $this->productRepository->removeCapacityAssignmentFromProducts( capacityAssignmentId: $capacityAssignmentId, ); } - $this->ticketRepository->addCapacityAssignmentToTickets( + $this->productRepository->addCapacityAssignmentToProducts( capacityAssignmentId: $capacityAssignmentId, - ticketIds: array_unique($ticketIds), + productIds: array_unique($productIds), ); } } diff --git a/backend/app/Services/Domain/CapacityAssignment/CreateCapacityAssignmentService.php b/backend/app/Services/Domain/CapacityAssignment/CreateCapacityAssignmentService.php index b5bb21a88f..4c7e68c6e6 100644 --- a/backend/app/Services/Domain/CapacityAssignment/CreateCapacityAssignmentService.php +++ b/backend/app/Services/Domain/CapacityAssignment/CreateCapacityAssignmentService.php @@ -5,11 +5,11 @@ use HiEvents\DomainObjects\CapacityAssignmentDomainObject; use HiEvents\DomainObjects\Enums\CapacityAssignmentAppliesTo; use HiEvents\DomainObjects\Generated\CapacityAssignmentDomainObjectAbstract; -use HiEvents\DomainObjects\TicketPriceDomainObject; +use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\Repository\Interfaces\CapacityAssignmentRepositoryInterface; -use HiEvents\Repository\Interfaces\TicketPriceRepositoryInterface; -use HiEvents\Services\Domain\Ticket\EventTicketValidationService; -use HiEvents\Services\Domain\Ticket\Exception\UnrecognizedTicketIdException; +use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface; +use HiEvents\Services\Domain\Product\EventProductValidationService; +use HiEvents\Services\Domain\Product\Exception\UnrecognizedProductIdException; use Illuminate\Database\DatabaseManager; class CreateCapacityAssignmentService @@ -17,32 +17,32 @@ class CreateCapacityAssignmentService public function __construct( private readonly DatabaseManager $databaseManager, private readonly CapacityAssignmentRepositoryInterface $capacityAssignmentRepository, - private readonly EventTicketValidationService $eventTicketValidationService, - private readonly CapacityAssignmentTicketAssociationService $capacityAssignmentTicketAssociationService, - private readonly TicketPriceRepositoryInterface $ticketPriceRepository, + private readonly EventProductValidationService $eventProductValidationService, + private readonly CapacityAssignmentProductAssociationService $capacityAssignmentProductAssociationService, + private readonly ProductPriceRepositoryInterface $productPriceRepository, ) { } /** - * @throws UnrecognizedTicketIdException + * @throws UnrecognizedProductIdException */ public function createCapacityAssignment( CapacityAssignmentDomainObject $capacityAssignment, - array $ticketIds, + array $productIds, ): CapacityAssignmentDomainObject { - $this->eventTicketValidationService->validateTicketIds($ticketIds, $capacityAssignment->getEventId()); + $this->eventProductValidationService->validateProductIds($productIds, $capacityAssignment->getEventId()); - return $this->persistAssignmentAndAssociateTickets($capacityAssignment, $ticketIds); + return $this->persistAssignmentAndAssociateProducts($capacityAssignment, $productIds); } - private function persistAssignmentAndAssociateTickets( + private function persistAssignmentAndAssociateProducts( CapacityAssignmentDomainObject $capacityAssignment, - ?array $ticketIds, + ?array $productIds, ): CapacityAssignmentDomainObject { - return $this->databaseManager->transaction(function () use ($capacityAssignment, $ticketIds) { + return $this->databaseManager->transaction(function () use ($capacityAssignment, $productIds) { /** @var CapacityAssignmentDomainObject $capacityAssignment */ $capacityAssignment = $this->capacityAssignmentRepository->create([ CapacityAssignmentDomainObjectAbstract::NAME => $capacityAssignment->getName(), @@ -50,13 +50,13 @@ private function persistAssignmentAndAssociateTickets( CapacityAssignmentDomainObjectAbstract::CAPACITY => $capacityAssignment->getCapacity(), CapacityAssignmentDomainObjectAbstract::APPLIES_TO => $capacityAssignment->getAppliesTo(), CapacityAssignmentDomainObjectAbstract::STATUS => $capacityAssignment->getStatus(), - CapacityAssignmentDomainObjectAbstract::USED_CAPACITY => $this->getUsedCapacity($ticketIds), + CapacityAssignmentDomainObjectAbstract::USED_CAPACITY => $this->getUsedCapacity($productIds), ]); - if ($capacityAssignment->getAppliesTo() === CapacityAssignmentAppliesTo::TICKETS->name) { - $this->capacityAssignmentTicketAssociationService->addCapacityToTickets( + if ($capacityAssignment->getAppliesTo() === CapacityAssignmentAppliesTo::PRODUCTS->name) { + $this->capacityAssignmentProductAssociationService->addCapacityToProducts( capacityAssignmentId: $capacityAssignment->getId(), - ticketIds: $ticketIds, + productIds: $productIds, removePreviousAssignments: false, ); } @@ -65,10 +65,10 @@ private function persistAssignmentAndAssociateTickets( }); } - private function getUsedCapacity(array $ticketIds): int + private function getUsedCapacity(array $productIds): int { - $ticketPrices = $this->ticketPriceRepository->findWhereIn('ticket_id', $ticketIds); + $productPrices = $this->productPriceRepository->findWhereIn('product_id', $productIds); - return $ticketPrices->sum(fn(TicketPriceDomainObject $ticketPrice) => $ticketPrice->getQuantitySold()); + return $productPrices->sum(fn(ProductPriceDomainObject $productPrice) => $productPrice->getQuantitySold()); } } diff --git a/backend/app/Services/Domain/CapacityAssignment/Exception/TicketsDoNotBelongToEventException.php b/backend/app/Services/Domain/CapacityAssignment/Exception/ProductsDoNotBelongToEventException.php similarity index 61% rename from backend/app/Services/Domain/CapacityAssignment/Exception/TicketsDoNotBelongToEventException.php rename to backend/app/Services/Domain/CapacityAssignment/Exception/ProductsDoNotBelongToEventException.php index 8e2cbefb8a..1505af45b5 100644 --- a/backend/app/Services/Domain/CapacityAssignment/Exception/TicketsDoNotBelongToEventException.php +++ b/backend/app/Services/Domain/CapacityAssignment/Exception/ProductsDoNotBelongToEventException.php @@ -4,7 +4,7 @@ use Exception; -class TicketsDoNotBelongToEventException extends Exception +class ProductsDoNotBelongToEventException extends Exception { } diff --git a/backend/app/Services/Domain/CapacityAssignment/UpdateCapacityAssignmentService.php b/backend/app/Services/Domain/CapacityAssignment/UpdateCapacityAssignmentService.php index a0d0899a27..b355cd00ac 100644 --- a/backend/app/Services/Domain/CapacityAssignment/UpdateCapacityAssignmentService.php +++ b/backend/app/Services/Domain/CapacityAssignment/UpdateCapacityAssignmentService.php @@ -6,8 +6,8 @@ use HiEvents\DomainObjects\Enums\CapacityAssignmentAppliesTo; use HiEvents\DomainObjects\Generated\CapacityAssignmentDomainObjectAbstract; use HiEvents\Repository\Interfaces\CapacityAssignmentRepositoryInterface; -use HiEvents\Services\Domain\Ticket\EventTicketValidationService; -use HiEvents\Services\Domain\Ticket\Exception\UnrecognizedTicketIdException; +use HiEvents\Services\Domain\Product\EventProductValidationService; +use HiEvents\Services\Domain\Product\Exception\UnrecognizedProductIdException; use Illuminate\Database\DatabaseManager; class UpdateCapacityAssignmentService @@ -15,33 +15,33 @@ class UpdateCapacityAssignmentService public function __construct( private readonly DatabaseManager $databaseManager, private readonly CapacityAssignmentRepositoryInterface $capacityAssignmentRepository, - private readonly EventTicketValidationService $eventTicketValidationService, - private readonly CapacityAssignmentTicketAssociationService $capacityAssignmentTicketAssociationService, + private readonly EventProductValidationService $eventProductValidationService, + private readonly CapacityAssignmentProductAssociationService $capacityAssignmentProductAssociationService, ) { } /** - * @throws UnrecognizedTicketIdException + * @throws UnrecognizedProductIdException */ public function updateCapacityAssignment( CapacityAssignmentDomainObject $capacityAssignment, - ?array $ticketIds = null, + ?array $productIds = null, ): CapacityAssignmentDomainObject { - if ($ticketIds !== null) { - $this->eventTicketValidationService->validateTicketIds($ticketIds, $capacityAssignment->getEventId()); + if ($productIds !== null) { + $this->eventProductValidationService->validateProductIds($productIds, $capacityAssignment->getEventId()); } - return $this->updateAssignmentAndAssociateTickets($capacityAssignment, $ticketIds); + return $this->updateAssignmentAndAssociateProducts($capacityAssignment, $productIds); } - private function updateAssignmentAndAssociateTickets( + private function updateAssignmentAndAssociateProducts( CapacityAssignmentDomainObject $capacityAssignment, - ?array $ticketIds + ?array $productIds ): CapacityAssignmentDomainObject { - return $this->databaseManager->transaction(function () use ($capacityAssignment, $ticketIds) { + return $this->databaseManager->transaction(function () use ($capacityAssignment, $productIds) { /** @var CapacityAssignmentDomainObject $capacityAssignment */ $this->capacityAssignmentRepository->updateWhere( attributes: [ @@ -57,10 +57,10 @@ private function updateAssignmentAndAssociateTickets( ] ); - if ($capacityAssignment->getAppliesTo() === CapacityAssignmentAppliesTo::TICKETS->name) { - $this->capacityAssignmentTicketAssociationService->addCapacityToTickets( + if ($capacityAssignment->getAppliesTo() === CapacityAssignmentAppliesTo::PRODUCTS->name) { + $this->capacityAssignmentProductAssociationService->addCapacityToProducts( capacityAssignmentId: $capacityAssignment->getId(), - ticketIds: $ticketIds, + productIds: $productIds, ); } diff --git a/backend/app/Services/Domain/CheckInList/CheckInListDataService.php b/backend/app/Services/Domain/CheckInList/CheckInListDataService.php index 3795ad043a..840a3bd7c1 100644 --- a/backend/app/Services/Domain/CheckInList/CheckInListDataService.php +++ b/backend/app/Services/Domain/CheckInList/CheckInListDataService.php @@ -7,7 +7,7 @@ use HiEvents\DomainObjects\CheckInListDomainObject; use HiEvents\DomainObjects\Generated\AttendeeDomainObjectAbstract; use HiEvents\DomainObjects\Generated\CheckInListDomainObjectAbstract; -use HiEvents\DomainObjects\TicketDomainObject; +use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\Exceptions\CannotCheckInException; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface; @@ -30,9 +30,9 @@ public function verifyAttendeeBelongsToCheckInList( AttendeeDomainObject $attendee, ): void { - $allowedTicketIds = $checkInList->getTickets()->map(fn($ticket) => $ticket->getId())->toArray() ?? []; + $allowedProductIds = $checkInList->getProducts()->map(fn($product) => $product->getId())->toArray() ?? []; - if (!in_array($attendee->getTicketId(), $allowedTicketIds, true)) { + if (!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(), @@ -47,9 +47,9 @@ public function verifyAttendeeBelongsToCheckInList( * * @throws CannotCheckInException */ - public function getAttendees(array $attendeePublicIds): Collection + public function getAttendees(Collection $attendeePublicIds): Collection { - $attendeePublicIds = array_unique($attendeePublicIds); + $attendeePublicIds = array_unique($attendeePublicIds->toArray()); $attendees = $this->attendeeRepository->findWhereIn( field: AttendeeDomainObjectAbstract::PUBLIC_ID, @@ -59,8 +59,8 @@ public function getAttendees(array $attendeePublicIds): Collection if (count($attendees) !== count($attendeePublicIds)) { throw new CannotCheckInException(__('Invalid attendee code detected: :attendees ', [ 'attendees' => implode(', ', array_diff( - $attendeePublicIds, - $attendees->pluck(AttendeeDomainObjectAbstract::PUBLIC_ID)->toArray()) + $attendeePublicIds, + $attendees->pluck(AttendeeDomainObjectAbstract::PUBLIC_ID)->toArray()) ), ])); } @@ -74,7 +74,7 @@ public function getAttendees(array $attendeePublicIds): Collection public function getCheckInList(string $checkInListUuid): CheckInListDomainObject { $checkInList = $this->checkInListRepository - ->loadRelation(TicketDomainObject::class) + ->loadRelation(ProductDomainObject::class) ->findFirstWhere([ CheckInListDomainObjectAbstract::SHORT_ID => $checkInListUuid, ]); diff --git a/backend/app/Services/Domain/CheckInList/CheckInListTicketAssociationService.php b/backend/app/Services/Domain/CheckInList/CheckInListProductAssociationService.php similarity index 50% rename from backend/app/Services/Domain/CheckInList/CheckInListTicketAssociationService.php rename to backend/app/Services/Domain/CheckInList/CheckInListProductAssociationService.php index 24c96c4e16..2153c44620 100644 --- a/backend/app/Services/Domain/CheckInList/CheckInListTicketAssociationService.php +++ b/backend/app/Services/Domain/CheckInList/CheckInListProductAssociationService.php @@ -2,52 +2,52 @@ namespace HiEvents\Services\Domain\CheckInList; -use HiEvents\Repository\Interfaces\TicketRepositoryInterface; +use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use Illuminate\Database\DatabaseManager; -class CheckInListTicketAssociationService +class CheckInListProductAssociationService { public function __construct( - private readonly TicketRepositoryInterface $ticketRepository, - public readonly DatabaseManager $databaseManager, + private readonly ProductRepositoryInterface $productRepository, + public readonly DatabaseManager $databaseManager, ) { } - public function addCheckInListToTickets( + public function addCheckInListToProducts( int $checkInListId, - ?array $ticketIds, + ?array $productIds, bool $removePreviousAssignments = true ): void { - $this->databaseManager->transaction(function () use ($checkInListId, $ticketIds, $removePreviousAssignments) { - $this->associateTicketsWithCheckInList( + $this->databaseManager->transaction(function () use ($checkInListId, $productIds, $removePreviousAssignments) { + $this->associateProductsWithCheckInList( checkInListId: $checkInListId, - ticketIds: $ticketIds, + productIds: $productIds, removePreviousAssignments: $removePreviousAssignments, ); }); } - private function associateTicketsWithCheckInList( + private function associateProductsWithCheckInList( int $checkInListId, - ?array $ticketIds, + ?array $productIds, bool $removePreviousAssignments = true ): void { - if (empty($ticketIds)) { + if (empty($productIds)) { return; } if ($removePreviousAssignments) { - $this->ticketRepository->removeCheckInListFromTickets( + $this->productRepository->removeCheckInListFromProducts( checkInListId: $checkInListId, ); } - $this->ticketRepository->addCheckInListToTickets( + $this->productRepository->addCheckInListToProducts( checkInListId: $checkInListId, - ticketIds: array_unique($ticketIds), + productIds: array_unique($productIds), ); } } diff --git a/backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInService.php b/backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInService.php index 737619b5ba..33ec004c91 100644 --- a/backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInService.php +++ b/backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInService.php @@ -6,92 +6,151 @@ use HiEvents\DataTransferObjects\ErrorBagDTO; use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\CheckInListDomainObject; +use HiEvents\DomainObjects\Enums\AttendeeCheckInActionType; +use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\Generated\AttendeeCheckInDomainObjectAbstract; use HiEvents\DomainObjects\Status\AttendeeStatus; use HiEvents\Exceptions\CannotCheckInException; use HiEvents\Helper\DateHelper; use HiEvents\Helper\IdHelper; use HiEvents\Repository\Interfaces\AttendeeCheckInRepositoryInterface; +use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface; +use HiEvents\Services\Application\Handlers\CheckInList\Public\DTO\AttendeeAndActionDTO; +use HiEvents\Services\Domain\CheckInList\DTO\CheckInResultDTO; use HiEvents\Services\Domain\CheckInList\DTO\CreateAttendeeCheckInsResponseDTO; +use HiEvents\Services\Domain\Order\MarkOrderAsPaidService; +use Illuminate\Database\ConnectionInterface; use Illuminate\Support\Collection; +use Throwable; class CreateAttendeeCheckInService { public function __construct( private readonly AttendeeCheckInRepositoryInterface $attendeeCheckInRepository, private readonly CheckInListDataService $checkInListDataService, + private readonly EventSettingsRepositoryInterface $eventSettingsRepository, + private readonly ConnectionInterface $db, + private readonly MarkOrderAsPaidService $markOrderAsPaidService, ) { } /** + * @param string $checkInListUuid + * @param string $checkInUserIpAddress + * @param Collection $attendeesAndActions + * @return CreateAttendeeCheckInsResponseDTO * @throws CannotCheckInException - * @throws Exception + * @throws Exception|Throwable */ public function checkInAttendees( - string $checkInListUuid, - string $checkInUserIpAddress, - array $attendeePublicIds + string $checkInListUuid, + string $checkInUserIpAddress, + Collection $attendeesAndActions ): CreateAttendeeCheckInsResponseDTO { - $attendees = $this->checkInListDataService->getAttendees($attendeePublicIds); $checkInList = $this->checkInListDataService->getCheckInList($checkInListUuid); - $this->validateCheckInListIsActive($checkInList); - $existingCheckIns = $this->attendeeCheckInRepository->findWhereIn( + $attendees = $this->fetchAttendees($attendeesAndActions); + $eventSettings = $this->fetchEventSettings($checkInList->getEventId()); + $existingCheckIns = $this->fetchExistingCheckIns($attendees, $checkInList->getEventId()); + + return $this->processAttendeeCheckIns( + $attendees, + $attendeesAndActions, + $checkInList, + $eventSettings, + $existingCheckIns, + $checkInUserIpAddress + ); + } + + /** + * @throws CannotCheckInException + */ + private function validateCheckInListIsActive(CheckInListDomainObject $checkInList): void + { + if ($checkInList->getExpiresAt() && DateHelper::utcDateIsPast($checkInList->getExpiresAt())) { + throw new CannotCheckInException(__('Check-in list has expired')); + } + + if ($checkInList->getActivatesAt() && DateHelper::utcDateIsFuture($checkInList->getActivatesAt())) { + throw new CannotCheckInException(__('Check-in list is not active yet')); + } + } + + /** + * @param Collection $attendeesAndActions + * @return Collection + * @throws CannotCheckInException + */ + private function fetchAttendees(Collection $attendeesAndActions): Collection + { + $publicIds = $attendeesAndActions->map( + fn(AttendeeAndActionDTO $attendeeAndAction) => $attendeeAndAction->public_id + ); + return $this->checkInListDataService->getAttendees($publicIds); + } + + private function fetchEventSettings(int $eventId): EventSettingDomainObject + { + return $this->eventSettingsRepository->findFirstWhere([ + 'event_id' => $eventId, + ]); + } + + /** + * @param Collection $attendees + * @param int $eventId + * @return Collection + * @throws Exception + */ + private function fetchExistingCheckIns(Collection $attendees, int $eventId): Collection + { + $attendeeIds = $attendees->map(fn(AttendeeDomainObject $attendee) => $attendee->getId())->toArray(); + + return $this->attendeeCheckInRepository->findWhereIn( field: AttendeeCheckInDomainObjectAbstract::ATTENDEE_ID, - values: $attendees->filter( - fn(AttendeeDomainObject $attendee) => in_array($attendee->getPublicId(), $attendeePublicIds, true) - )->map( - fn(AttendeeDomainObject $attendee) => $attendee->getId() - )->toArray(), + values: $attendeeIds, additionalWhere: [ - AttendeeCheckInDomainObjectAbstract::EVENT_ID => $checkInList->getEventId(), + AttendeeCheckInDomainObjectAbstract::EVENT_ID => $eventId, ], ); + } + /** + * @throws Throwable + * @throws CannotCheckInException + */ + private function processAttendeeCheckIns( + Collection $attendees, + Collection $attendeesAndActions, + CheckInListDomainObject $checkInList, + EventSettingDomainObject $eventSettings, + Collection $existingCheckIns, + string $checkInUserIpAddress + ): CreateAttendeeCheckInsResponseDTO + { $errors = new ErrorBagDTO(); $checkIns = new Collection(); foreach ($attendees as $attendee) { - $this->checkInListDataService->verifyAttendeeBelongsToCheckInList($checkInList, $attendee); - - $existingCheckIn = $existingCheckIns->first( - fn($checkIn) => $checkIn->getAttendeeId() === $attendee->getId() + $result = $this->processIndividualCheckIn( + $attendee, + $attendeesAndActions, + $checkInList, + $eventSettings, + $existingCheckIns, + $checkInUserIpAddress ); - if ($attendee->getStatus() === AttendeeStatus::CANCELLED->name) { - $errors->addError( - key: $attendee->getPublicId(), - message: __('Attendee :attendee_name\'s ticket is cancelled', [ - 'attendee_name' => $attendee->getFullName(), - ]) - ); - continue; + if ($result->checkIn) { + $checkIns->push($result->checkIn); } - - if ($existingCheckIn) { - $checkIns->push($existingCheckIn); - $errors->addError( - key: $attendee->getPublicId(), - message: __('Attendee :attendee_name is already checked in', [ - 'attendee_name' => $attendee->getFullName(), - ]) - ); - continue; + if ($result->error) { + $errors->addError($attendee->getPublicId(), $result->error); } - - $checkIns->push( - $this->attendeeCheckInRepository->create([ - AttendeeCheckInDomainObjectAbstract::ATTENDEE_ID => $attendee->getId(), - AttendeeCheckInDomainObjectAbstract::CHECK_IN_LIST_ID => $checkInList->getId(), - AttendeeCheckInDomainObjectAbstract::IP_ADDRESS => $checkInUserIpAddress, - AttendeeCheckInDomainObjectAbstract::TICKET_ID => $attendee->getTicketId(), - AttendeeCheckInDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::CHECK_IN_PREFIX), - AttendeeCheckInDomainObjectAbstract::EVENT_ID => $checkInList->getEventId(), - ]) - ); } return new CreateAttendeeCheckInsResponseDTO( @@ -101,16 +160,105 @@ public function checkInAttendees( } /** + * @throws Throwable * @throws CannotCheckInException */ - private function validateCheckInListIsActive(CheckInListDomainObject $checkInList): void + private function processIndividualCheckIn( + AttendeeDomainObject $attendee, + Collection $attendeesAndActions, + CheckInListDomainObject $checkInList, + EventSettingDomainObject $eventSettings, + Collection $existingCheckIns, + string $checkInUserIpAddress + ): CheckInResultDTO { - if ($checkInList->getExpiresAt() && DateHelper::utcDateIsPast($checkInList->getExpiresAt())) { - throw new CannotCheckInException(__('Check-in list has expired')); + $this->checkInListDataService->verifyAttendeeBelongsToCheckInList($checkInList, $attendee); + + $attendeeAction = $attendeesAndActions->first( + fn(AttendeeAndActionDTO $action) => $action->public_id === $attendee->getPublicId() + ); + $checkInAction = $attendeeAction->action; + + if ($existingCheckIn = $this->getExistingCheckIn($existingCheckIns, $attendee)) { + return new CheckInResultDTO( + checkIn: $existingCheckIn, + error: __('Attendee :attendee_name is already checked in', [ + 'attendee_name' => $attendee->getFullName(), + ]) + ); } - if ($checkInList->getActivatesAt() && DateHelper::utcDateIsFuture($checkInList->getActivatesAt())) { - throw new CannotCheckInException(__('Check-in list is not active yes')); + if ($error = $this->validateAttendeeStatus($attendee, $checkInAction, $eventSettings)) { + return new CheckInResultDTO(error: $error); } + + return $this->db->transaction(function () use ($attendee, $checkInList, $checkInAction, $checkInUserIpAddress) { + $checkIn = $this->createCheckIn($attendee, $checkInList, $checkInUserIpAddress); + + if ($checkInAction->value === AttendeeCheckInActionType::CHECK_IN_AND_MARK_ORDER_AS_PAID->value) { + $this->markOrderAsPaidService->markOrderAsPaid( + orderId: $attendee->getOrderId(), + eventId: $attendee->getEventId(), + ); + } + + return new CheckInResultDTO(checkIn: $checkIn); + }); + } + + private function getExistingCheckIn(Collection $existingCheckIns, AttendeeDomainObject $attendee): ?object + { + return $existingCheckIns->first( + fn($checkIn) => $checkIn->getAttendeeId() === $attendee->getId() + ); + } + + private function validateAttendeeStatus( + AttendeeDomainObject $attendee, + AttendeeCheckInActionType $checkInAction, + EventSettingDomainObject $eventSettings + ): ?string + { + $allowAttendeesAwaitingPaymentToCheckIn = $eventSettings->getAllowOrdersAwaitingOfflinePaymentToCheckIn(); + + if ($attendee->getStatus() === AttendeeStatus::CANCELLED->name) { + return __('Attendee :attendee_name\'s ticket is cancelled', [ + 'attendee_name' => $attendee->getFullName(), + ]); + } + + if (!$allowAttendeesAwaitingPaymentToCheckIn) { + if ($checkInAction->value === AttendeeCheckInActionType::CHECK_IN->value + && $attendee->getStatus() === AttendeeStatus::AWAITING_PAYMENT->name + ) { + return __('Unable to check in as attendee :attendee_name\'s order is awaiting payment', [ + 'attendee_name' => $attendee->getFullName(), + ]); + } + + if ($checkInAction->value === AttendeeCheckInActionType::CHECK_IN_AND_MARK_ORDER_AS_PAID->value) { + return __('Attendee :attendee_name\'s order cannot be marked as paid. Please check your event settings', [ + 'attendee_name' => $attendee->getFullName(), + ]); + } + } + + return null; + } + + private function createCheckIn( + AttendeeDomainObject $attendee, + CheckInListDomainObject $checkInList, + string $checkInUserIpAddress + ): object + { + return $this->attendeeCheckInRepository->create([ + AttendeeCheckInDomainObjectAbstract::ATTENDEE_ID => $attendee->getId(), + AttendeeCheckInDomainObjectAbstract::CHECK_IN_LIST_ID => $checkInList->getId(), + AttendeeCheckInDomainObjectAbstract::IP_ADDRESS => $checkInUserIpAddress, + AttendeeCheckInDomainObjectAbstract::PRODUCT_ID => $attendee->getProductId(), + AttendeeCheckInDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::CHECK_IN_PREFIX), + AttendeeCheckInDomainObjectAbstract::EVENT_ID => $checkInList->getEventId(), + ]); } } diff --git a/backend/app/Services/Domain/CheckInList/CreateCheckInListService.php b/backend/app/Services/Domain/CheckInList/CreateCheckInListService.php index 93aa2938de..a4e95f929c 100644 --- a/backend/app/Services/Domain/CheckInList/CreateCheckInListService.php +++ b/backend/app/Services/Domain/CheckInList/CreateCheckInListService.php @@ -8,16 +8,16 @@ use HiEvents\Helper\IdHelper; use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; -use HiEvents\Services\Domain\Ticket\EventTicketValidationService; -use HiEvents\Services\Domain\Ticket\Exception\UnrecognizedTicketIdException; +use HiEvents\Services\Domain\Product\EventProductValidationService; +use HiEvents\Services\Domain\Product\Exception\UnrecognizedProductIdException; use Illuminate\Database\DatabaseManager; class CreateCheckInListService { public function __construct( private readonly CheckInListRepositoryInterface $checkInListRepository, - private readonly EventTicketValidationService $eventTicketValidationService, - private readonly CheckInListTicketAssociationService $checkInListTicketAssociationService, + private readonly EventProductValidationService $eventProductValidationService, + private readonly CheckInListProductAssociationService $checkInListProductAssociationService, private readonly DatabaseManager $databaseManager, private readonly EventRepositoryInterface $eventRepository, @@ -26,12 +26,12 @@ public function __construct( } /** - * @throws UnrecognizedTicketIdException + * @throws UnrecognizedProductIdException */ - public function createCheckInList(CheckInListDomainObject $checkInList, array $ticketIds): CheckInListDomainObject + public function createCheckInList(CheckInListDomainObject $checkInList, array $productIds): CheckInListDomainObject { - return $this->databaseManager->transaction(function () use ($checkInList, $ticketIds) { - $this->eventTicketValidationService->validateTicketIds($ticketIds, $checkInList->getEventId()); + return $this->databaseManager->transaction(function () use ($checkInList, $productIds) { + $this->eventProductValidationService->validateProductIds($productIds, $checkInList->getEventId()); $event = $this->eventRepository->findById($checkInList->getEventId()); $newCheckInList = $this->checkInListRepository->create([ @@ -47,9 +47,9 @@ public function createCheckInList(CheckInListDomainObject $checkInList, array $t CheckInListDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::CHECK_IN_LIST_PREFIX), ]); - $this->checkInListTicketAssociationService->addCheckInListToTickets( + $this->checkInListProductAssociationService->addCheckInListToProducts( checkInListId: $newCheckInList->getId(), - ticketIds: $ticketIds, + productIds: $productIds, removePreviousAssignments: false, ); diff --git a/backend/app/Services/Domain/CheckInList/DTO/CheckInResultDTO.php b/backend/app/Services/Domain/CheckInList/DTO/CheckInResultDTO.php new file mode 100644 index 0000000000..1eea6fc352 --- /dev/null +++ b/backend/app/Services/Domain/CheckInList/DTO/CheckInResultDTO.php @@ -0,0 +1,13 @@ +attendeeCheckInRepository @@ -46,5 +46,7 @@ public function deleteAttendeeCheckIn( } $this->attendeeCheckInRepository->deleteById($checkIn->getId()); + + return $checkIn->getId(); } } diff --git a/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php b/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php index b26fd8b6c1..11a441deaf 100644 --- a/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php +++ b/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php @@ -7,16 +7,16 @@ use HiEvents\Helper\DateHelper; use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; -use HiEvents\Services\Domain\Ticket\EventTicketValidationService; -use HiEvents\Services\Domain\Ticket\Exception\UnrecognizedTicketIdException; +use HiEvents\Services\Domain\Product\EventProductValidationService; +use HiEvents\Services\Domain\Product\Exception\UnrecognizedProductIdException; use Illuminate\Database\DatabaseManager; class UpdateCheckInListService { public function __construct( private readonly DatabaseManager $databaseManager, - private readonly EventTicketValidationService $eventTicketValidationService, - private readonly CheckInListTicketAssociationService $checkInListTicketAssociationService, + private readonly EventProductValidationService $eventProductValidationService, + private readonly CheckInListProductAssociationService $checkInListProductAssociationService, private readonly CheckInListRepositoryInterface $checkInListRepository, private readonly EventRepositoryInterface $eventRepository, ) @@ -24,12 +24,12 @@ public function __construct( } /** - * @throws UnrecognizedTicketIdException + * @throws UnrecognizedProductIdException */ - public function updateCheckInList(CheckInListDomainObject $checkInList, array $ticketIds): CheckInListDomainObject + public function updateCheckInList(CheckInListDomainObject $checkInList, array $productIds): CheckInListDomainObject { - return $this->databaseManager->transaction(function () use ($checkInList, $ticketIds) { - $this->eventTicketValidationService->validateTicketIds($ticketIds, $checkInList->getEventId()); + return $this->databaseManager->transaction(function () use ($checkInList, $productIds) { + $this->eventProductValidationService->validateProductIds($productIds, $checkInList->getEventId()); $event = $this->eventRepository->findById($checkInList->getEventId()); $this->checkInListRepository->updateWhere( @@ -50,9 +50,9 @@ public function updateCheckInList(CheckInListDomainObject $checkInList, array $t ] ); - $this->checkInListTicketAssociationService->addCheckInListToTickets( + $this->checkInListProductAssociationService->addCheckInListToProducts( checkInListId: $checkInList->getId(), - ticketIds: $ticketIds, + productIds: $productIds, ); return $this->checkInListRepository->findFirstWhere( diff --git a/backend/app/Services/Domain/CreateWebhookService.php b/backend/app/Services/Domain/CreateWebhookService.php new file mode 100644 index 0000000000..7ea1b663a7 --- /dev/null +++ b/backend/app/Services/Domain/CreateWebhookService.php @@ -0,0 +1,39 @@ +webhookRepository->create([ + WebhookDomainObjectAbstract::URL => $webhookDomainObject->getUrl(), + WebhookDomainObjectAbstract::EVENT_TYPES => $webhookDomainObject->getEventTypes(), + WebhookDomainObjectAbstract::ACCOUNT_ID => $webhookDomainObject->getAccountId(), + WebhookDomainObjectAbstract::STATUS => $webhookDomainObject->getStatus(), + WebhookDomainObjectAbstract::EVENT_ID => $webhookDomainObject->getEventId(), + WebhookDomainObjectAbstract::USER_ID => $webhookDomainObject->getUserId(), + WebhookDomainObjectAbstract::SECRET => Str::random(32), + ]); + + $this->logger->info('Created webhook', [ + 'webhook' => $webhookDomainObject->toArray(), + ]); + + return $webhook; + } +} diff --git a/backend/app/Services/Domain/Event/CreateEventImageService.php b/backend/app/Services/Domain/Event/CreateEventImageService.php index a2b6eb73ba..7c9f855742 100644 --- a/backend/app/Services/Domain/Event/CreateEventImageService.php +++ b/backend/app/Services/Domain/Event/CreateEventImageService.php @@ -43,7 +43,7 @@ public function createImage( image: $image, entityId: $eventId, entityType: EventDomainObject::class, - imageType: EventImageType::EVENT_COVER->name, + imageType: $type->name, ); }); } diff --git a/backend/app/Services/Domain/Event/CreateEventService.php b/backend/app/Services/Domain/Event/CreateEventService.php index 4d8fdd8648..915918c64a 100644 --- a/backend/app/Services/Domain/Event/CreateEventService.php +++ b/backend/app/Services/Domain/Event/CreateEventService.php @@ -3,6 +3,7 @@ namespace HiEvents\Services\Domain\Event; use HiEvents\DomainObjects\Enums\HomepageBackgroundType; +use HiEvents\DomainObjects\Enums\PaymentProviders; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; @@ -13,7 +14,7 @@ use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface; use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; -use HTMLPurifier; +use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService; use Illuminate\Database\DatabaseManager; use Throwable; @@ -25,7 +26,7 @@ public function __construct( private readonly OrganizerRepositoryInterface $organizerRepository, private readonly DatabaseManager $databaseManager, private readonly EventStatisticRepositoryInterface $eventStatisticsRepository, - private readonly HTMLPurifier $purifier, + private readonly HtmlPurifierService $purifier, ) { } @@ -38,26 +39,24 @@ public function createEvent( EventSettingDomainObject $eventSettings = null ): EventDomainObject { - $this->databaseManager->beginTransaction(); - - $organizer = $this->getOrganizer( - organizerId: $eventData->getOrganizerId(), - accountId: $eventData->getAccountId() - ); - - $event = $this->handleEventCreate($eventData); + return $this->databaseManager->transaction(function () use ($eventData, $eventSettings) { + $organizer = $this->getOrganizer( + organizerId: $eventData->getOrganizerId(), + accountId: $eventData->getAccountId() + ); - $this->createEventSettings( - eventSettings: $eventSettings, - event: $event, - organizer: $organizer - ); + $event = $this->handleEventCreate($eventData); - $this->createEventStatistics($event); + $this->createEventSettings( + eventSettings: $eventSettings, + event: $event, + organizer: $organizer + ); - $this->databaseManager->commit(); + $this->createEventStatistics($event); - return $event; + return $event; + }); } /** @@ -104,7 +103,7 @@ private function createEventStatistics(EventDomainObject $event): void { $this->eventStatisticsRepository->create([ 'event_id' => $event->getId(), - 'tickets_sold' => 0, + 'products_sold' => 0, 'sales_total_gross' => 0, 'sales_total_before_additions' => 0, 'total_tax' => 0, @@ -141,6 +140,18 @@ private function createEventSettings( 'homepage_body_background_color' => '#7a5eb9', 'continue_button_text' => __('Continue'), 'support_email' => $organizer->getEmail(), + + 'payment_providers' => [PaymentProviders::STRIPE->value], + 'offline_payment_instructions' => null, + + 'enable_invoicing' => false, + 'invoice_label' => __('Invoice'), + 'invoice_prefix' => 'INV-', + 'invoice_start_number' => 1, + 'require_billing_address' => false, + 'organization_name' => $organizer->getName(), + 'organization_address' => null, + 'invoice_tax_details' => null, ]); } } diff --git a/backend/app/Services/Domain/Event/DTO/DuplicateEventDataDTO.php b/backend/app/Services/Domain/Event/DTO/DuplicateEventDataDTO.php index 83262ea5e8..c8cd5de804 100644 --- a/backend/app/Services/Domain/Event/DTO/DuplicateEventDataDTO.php +++ b/backend/app/Services/Domain/Event/DTO/DuplicateEventDataDTO.php @@ -11,13 +11,14 @@ public function __construct( public int $accountId, public string $title, public string $startDate, - public bool $duplicateTickets = true, + public bool $duplicateProducts = true, public bool $duplicateQuestions = true, public bool $duplicateSettings = true, public bool $duplicatePromoCodes = true, public bool $duplicateCapacityAssignments = true, public bool $duplicateCheckInLists = true, public bool $duplicateEventCoverImage = true, + public bool $duplicateWebhooks = true, public ?string $description = null, public ?string $endDate = null, ) diff --git a/backend/app/Services/Domain/Event/DTO/EventDailyStatsResponseDTO.php b/backend/app/Services/Domain/Event/DTO/EventDailyStatsResponseDTO.php index f6d88472de..e15c8fd1c5 100644 --- a/backend/app/Services/Domain/Event/DTO/EventDailyStatsResponseDTO.php +++ b/backend/app/Services/Domain/Event/DTO/EventDailyStatsResponseDTO.php @@ -9,8 +9,11 @@ public function __construct( public float $total_fees, public float $total_tax, public float $total_sales_gross, - public int $tickets_sold, + public int $products_sold, public int $orders_created, + public int $attendees_registered, + public float $total_refunded, + ) { } diff --git a/backend/app/Services/Domain/Event/DuplicateEventService.php b/backend/app/Services/Domain/Event/DuplicateEventService.php index a8dd866872..ed249a82fd 100644 --- a/backend/app/Services/Domain/Event/DuplicateEventService.php +++ b/backend/app/Services/Domain/Event/DuplicateEventService.php @@ -9,21 +9,25 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\ImageDomainObject; +use HiEvents\DomainObjects\ProductCategoryDomainObject; +use HiEvents\DomainObjects\ProductDomainObject; +use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\DomainObjects\PromoCodeDomainObject; use HiEvents\DomainObjects\QuestionDomainObject; use HiEvents\DomainObjects\Status\EventStatus; use HiEvents\DomainObjects\TaxAndFeesDomainObject; -use HiEvents\DomainObjects\TicketDomainObject; -use HiEvents\DomainObjects\TicketPriceDomainObject; +use HiEvents\DomainObjects\WebhookDomainObject; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\ImageRepositoryInterface; use HiEvents\Services\Domain\CapacityAssignment\CreateCapacityAssignmentService; use HiEvents\Services\Domain\CheckInList\CreateCheckInListService; +use HiEvents\Services\Domain\CreateWebhookService; +use HiEvents\Services\Domain\Product\CreateProductService; +use HiEvents\Services\Domain\ProductCategory\CreateProductCategoryService; use HiEvents\Services\Domain\PromoCode\CreatePromoCodeService; use HiEvents\Services\Domain\Question\CreateQuestionService; -use HiEvents\Services\Domain\Ticket\CreateTicketService; -use HTMLPurifier; +use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService; use Illuminate\Database\DatabaseManager; use Throwable; @@ -32,14 +36,16 @@ class DuplicateEventService public function __construct( private readonly EventRepositoryInterface $eventRepository, private readonly CreateEventService $createEventService, - private readonly CreateTicketService $createTicketService, + 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 HTMLPurifier $purifier, + private readonly HtmlPurifierService $purifier, + private readonly CreateProductCategoryService $createProductCategoryService, + private readonly CreateWebhookService $createWebhookService, ) { } @@ -52,13 +58,14 @@ public function duplicateEvent( string $accountId, string $title, string $startDate, - bool $duplicateTickets = true, + bool $duplicateProducts = true, bool $duplicateQuestions = true, bool $duplicateSettings = true, bool $duplicatePromoCodes = true, bool $duplicateCapacityAssignments = true, bool $duplicateCheckInLists = true, bool $duplicateEventCoverImage = true, + bool $duplicateWebhooks = true, ?string $description = null, ?string $endDate = null, ): EventDomainObject @@ -84,8 +91,8 @@ public function duplicateEvent( $this->clonePerOrderQuestions($event, $newEvent->getId()); } - if ($duplicateTickets) { - $this->cloneExistingTickets( + if ($duplicateProducts) { + $this->cloneExistingProducts( event: $event, newEventId: $newEvent->getId(), duplicateQuestions: $duplicateQuestions, @@ -93,12 +100,18 @@ public function duplicateEvent( duplicateCapacityAssignments: $duplicateCapacityAssignments, duplicateCheckInLists: $duplicateCheckInLists, ); + } else { + $this->createProductCategoryService->createDefaultProductCategory($newEvent); } if ($duplicateEventCoverImage) { $this->cloneEventCoverImage($event, $newEvent->getId()); } + if ($duplicateWebhooks) { + $this->duplicateWebhooks($event, $newEvent); + } + $this->databaseManager->commit(); return $this->getEventWithRelations($newEvent->getId(), $newEvent->getAccountId()); @@ -136,53 +149,64 @@ private function cloneExistingEvent(EventDomainObject $event, bool $cloneEventSe /** * @throws Throwable */ - private function cloneExistingTickets( + private function cloneExistingProducts( EventDomainObject $event, int $newEventId, bool $duplicateQuestions, bool $duplicatePromoCodes, bool $duplicateCapacityAssignments, bool $duplicateCheckInLists, - ): array + ): void { - $oldTicketToNewTicketMap = []; + $oldProductToNewProductMap = []; - foreach ($event->getTickets() as $ticket) { - $ticket->setEventId($newEventId); - $newTicket = $this->createTicketService->createTicket( - ticket: $ticket, - accountId: $event->getAccountId(), - taxAndFeeIds: $ticket->getTaxAndFees()?->map(fn($taxAndFee) => $taxAndFee->getId())?->toArray(), + $event->getProductCategories()?->each(function (ProductCategoryDomainObject $productCategory) use ($event, $newEventId, &$oldProductToNewProductMap) { + $newCategory = $this->createProductCategoryService->createCategory( + (new ProductCategoryDomainObject()) + ->setName($productCategory->getName()) + ->setNoProductsMessage($productCategory->getNoProductsMessage()) + ->setDescription($productCategory->getDescription()) + ->setIsHidden($productCategory->getIsHidden()) + ->setEventId($newEventId), ); - $oldTicketToNewTicketMap[$ticket->getId()] = $newTicket->getId(); - } + + /** @var ProductDomainObject $product */ + foreach ($productCategory->getProducts() as $product) { + $product->setEventId($newEventId); + $product->setProductCategoryId($newCategory->getId()); + $newProduct = $this->createProductService->createProduct( + product: $product, + accountId: $event->getAccountId(), + taxAndFeeIds: $product->getTaxAndFees()?->map(fn($taxAndFee) => $taxAndFee->getId())?->toArray(), + ); + $oldProductToNewProductMap[$product->getId()] = $newProduct->getId(); + } + }); if ($duplicateQuestions) { - $this->clonePerTicketQuestions($event, $newEventId, $oldTicketToNewTicketMap); + $this->clonePerProductQuestions($event, $newEventId, $oldProductToNewProductMap); } if ($duplicatePromoCodes) { - $this->clonePromoCodes($event, $newEventId, $oldTicketToNewTicketMap); + $this->clonePromoCodes($event, $newEventId, $oldProductToNewProductMap); } if ($duplicateCapacityAssignments) { - $this->cloneCapacityAssignments($event, $newEventId, $oldTicketToNewTicketMap); + $this->cloneCapacityAssignments($event, $newEventId, $oldProductToNewProductMap); } if ($duplicateCheckInLists) { - $this->cloneCheckInLists($event, $newEventId, $oldTicketToNewTicketMap); + $this->cloneCheckInLists($event, $newEventId, $oldProductToNewProductMap); } - - return $oldTicketToNewTicketMap; } /** * @throws Throwable */ - private function clonePerTicketQuestions(EventDomainObject $event, int $newEventId, array $oldTicketToNewTicketMap): void + private function clonePerProductQuestions(EventDomainObject $event, int $newEventId, array $oldProductToNewProductMap): void { foreach ($event->getQuestions() as $question) { - if ($question->getBelongsTo() === QuestionBelongsTo::TICKET->name) { + if ($question->getBelongsTo() === QuestionBelongsTo::PRODUCT->name) { $this->createQuestionService->createQuestion( (new QuestionDomainObject()) ->setTitle($question->getTitle()) @@ -193,8 +217,8 @@ private function clonePerTicketQuestions(EventDomainObject $event, int $newEvent ->setOptions($question->getOptions()) ->setIsHidden($question->getIsHidden()), array_map( - static fn(TicketDomainObject $ticket) => $oldTicketToNewTicketMap[$ticket->getId()], - $question->getTickets()?->all(), + static fn(ProductDomainObject $product) => $oldProductToNewProductMap[$product->getId()], + $question->getProducts()?->all(), ), ); } @@ -226,16 +250,16 @@ private function clonePerOrderQuestions(EventDomainObject $event, int $newEventI /** * @throws Throwable */ - private function clonePromoCodes(EventDomainObject $event, int $newEventId, array $oldTicketToNewTicketMap): void + private function clonePromoCodes(EventDomainObject $event, int $newEventId, array $oldProductToNewProductMap): void { foreach ($event->getPromoCodes() as $promoCode) { $this->createPromoCodeService->createPromoCode( (new PromoCodeDomainObject()) ->setCode($promoCode->getCode()) ->setEventId($newEventId) - ->setApplicableTicketIds(array_map( - static fn($ticketId) => $oldTicketToNewTicketMap[$ticketId], - $promoCode->getApplicableTicketIds() ?? [], + ->setApplicableProductIds(array_map( + static fn($productId) => $oldProductToNewProductMap[$productId], + $promoCode->getApplicableProductIds() ?? [], )) ->setDiscountType($promoCode->getDiscountType()) ->setDiscount($promoCode->getDiscount()) @@ -245,7 +269,7 @@ private function clonePromoCodes(EventDomainObject $event, int $newEventId, arra } } - private function cloneCapacityAssignments(EventDomainObject $event, int $newEventId, $oldTicketToNewTicketMap): void + private function cloneCapacityAssignments(EventDomainObject $event, int $newEventId, $oldProductToNewProductMap): void { /** @var CapacityAssignmentDomainObject $capacityAssignment */ foreach ($event->getCapacityAssignments() as $capacityAssignment) { @@ -256,13 +280,13 @@ private function cloneCapacityAssignments(EventDomainObject $event, int $newEven ->setCapacity($capacityAssignment->getCapacity()) ->setAppliesTo($capacityAssignment->getAppliesTo()) ->setStatus($capacityAssignment->getStatus()), - ticketIds: $capacityAssignment->getTickets() - ?->map(fn($ticket) => $oldTicketToNewTicketMap[$ticket->getId()])?->toArray() ?? [], + productIds: $capacityAssignment->getProducts() + ?->map(fn($product) => $oldProductToNewProductMap[$product->getId()])?->toArray() ?? [], ); } } - private function cloneCheckInLists(EventDomainObject $event, int $newEventId, $oldTicketToNewTicketMap): void + private function cloneCheckInLists(EventDomainObject $event, int $newEventId, $oldProductToNewProductMap): void { foreach ($event->getCheckInLists() as $checkInList) { $this->createCheckInListService->createCheckInList( @@ -272,8 +296,8 @@ private function cloneCheckInLists(EventDomainObject $event, int $newEventId, $o ->setExpiresAt($checkInList->getExpiresAt()) ->setActivatesAt($checkInList->getActivatesAt()) ->setEventId($newEventId), - ticketIds: $checkInList->getTickets() - ?->map(fn($ticket) => $oldTicketToNewTicketMap[$ticket->getId()])?->toArray() ?? [], + productIds: $checkInList->getProducts() + ?->map(fn($product) => $oldProductToNewProductMap[$product->getId()])?->toArray() ?? [], ); } } @@ -301,25 +325,44 @@ private function getEventWithRelations(string $eventId, string $accountId): Even return $this->eventRepository ->loadRelation(EventSettingDomainObject::class) ->loadRelation( - new Relationship(TicketDomainObject::class, [ - new Relationship(TicketPriceDomainObject::class), - new Relationship(TaxAndFeesDomainObject::class) + new Relationship(ProductCategoryDomainObject::class, [ + new Relationship(ProductDomainObject::class, [ + new Relationship(ProductPriceDomainObject::class), + new Relationship(TaxAndFeesDomainObject::class), + ]), ]) ) ->loadRelation(PromoCodeDomainObject::class) ->loadRelation(new Relationship(QuestionDomainObject::class, [ - new Relationship(TicketDomainObject::class), + new Relationship(ProductDomainObject::class), ])) ->loadRelation(new Relationship(CapacityAssignmentDomainObject::class, [ - new Relationship(TicketDomainObject::class), + new Relationship(ProductDomainObject::class), ])) ->loadRelation(new Relationship(CheckInListDomainObject::class, [ - new Relationship(TicketDomainObject::class), + new Relationship(ProductDomainObject::class), ])) ->loadRelation(ImageDomainObject::class) + ->loadRelation(WebhookDomainObject::class) ->findFirstWhere([ 'id' => $eventId, 'account_id' => $accountId, ]); } + + private function duplicateWebhooks(EventDomainObject $event, EventDomainObject $newEvent): void + { + $event->getWebhooks()?->each(function (WebhookDomainObject $webhook) use ($newEvent) { + $this->createWebhookService->createWebhook( + (new WebhookDomainObject()) + ->setEventId($newEvent->getId()) + ->setUrl($webhook->getUrl()) + ->setSecret($webhook->getSecret()) + ->setEventTypes($webhook->getEventTypes()) + ->setStatus($webhook->getStatus()) + ->setAccountId($newEvent->getAccountId()) + ->setUserId($newEvent->getUserId()), + ); + }); + } } diff --git a/backend/app/Services/Domain/Event/EventStatsFetchService.php b/backend/app/Services/Domain/Event/EventStatsFetchService.php index 5344d7e4fd..8ec5a71423 100644 --- a/backend/app/Services/Domain/Event/EventStatsFetchService.php +++ b/backend/app/Services/Domain/Event/EventStatsFetchService.php @@ -3,10 +3,10 @@ namespace HiEvents\Services\Domain\Event; use Carbon\Carbon; +use HiEvents\Services\Application\Handlers\Event\DTO\EventStatsRequestDTO; +use HiEvents\Services\Application\Handlers\Event\DTO\EventStatsResponseDTO; use HiEvents\Services\Domain\Event\DTO\EventCheckInStatsResponseDTO; use HiEvents\Services\Domain\Event\DTO\EventDailyStatsResponseDTO; -use HiEvents\Services\Handlers\Event\DTO\EventStatsRequestDTO; -use HiEvents\Services\Handlers\Event\DTO\EventStatsResponseDTO; use Illuminate\Database\DatabaseManager; use Illuminate\Support\Collection; @@ -25,12 +25,15 @@ public function getEventStats(EventStatsRequestDTO $requestData): EventStatsResp // Aggregate total statistics for the event for all time $totalsQuery = <<start_date, end_date: $requestData->end_date, check_in_stats: $this->getCheckedInStats($eventId), - total_tickets_sold: $totalsResult->total_tickets_sold ?? 0, + total_products_sold: $totalsResult->total_products_sold ?? 0, + total_attendees_registered: $totalsResult->attendees_registered ?? 0, total_orders: $totalsResult->total_orders ?? 0, total_gross_sales: $totalsResult->total_gross_sales ?? 0, total_fees: $totalsResult->total_fees ?? 0, total_tax: $totalsResult->total_tax ?? 0, total_views: $totalsResult->total_views ?? 0, - + total_refunded: $totalsResult->total_refunded ?? 0, ); } @@ -77,7 +81,9 @@ public function getDailyEventStats(EventStatsRequestDTO $requestData): Collectio COALESCE(SUM(eds.total_tax), 0) AS total_tax, COALESCE(SUM(eds.sales_total_gross), 0) AS total_sales_gross, COALESCE(SUM(eds.orders_created), 0) AS orders_created, - COALESCE(SUM(eds.tickets_sold), 0) AS tickets_sold + COALESCE(SUM(eds.products_sold), 0) AS products_sold, + COALESCE(SUM(eds.attendees_registered), 0) AS attendees_registered, + COALESCE(SUM(eds.total_refunded), 0) AS total_refunded FROM date_series ds LEFT JOIN event_daily_statistics eds ON ds.date = eds.date AND eds.deleted_at IS NULL AND eds.event_id = :eventId GROUP BY ds.date @@ -100,8 +106,10 @@ public function getDailyEventStats(EventStatsRequestDTO $requestData): Collectio total_fees: $result->total_fees, total_tax: $result->total_tax, total_sales_gross: $result->total_sales_gross, - tickets_sold: $result->tickets_sold, + products_sold: $result->products_sold, orders_created: $result->orders_created, + attendees_registered: $result->attendees_registered, + total_refunded: $result->total_refunded, ); }); } diff --git a/backend/app/Services/Domain/EventStatistics/EventStatisticsUpdateService.php b/backend/app/Services/Domain/EventStatistics/EventStatisticsUpdateService.php index 7da0c4bfd3..f62b1ede44 100644 --- a/backend/app/Services/Domain/EventStatistics/EventStatisticsUpdateService.php +++ b/backend/app/Services/Domain/EventStatistics/EventStatisticsUpdateService.php @@ -2,16 +2,16 @@ namespace HiEvents\Services\Domain\EventStatistics; +use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract; use HiEvents\DomainObjects\Generated\PromoCodeDomainObjectAbstract; -use HiEvents\DomainObjects\Generated\TicketDomainObjectAbstract; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\Exceptions\EventStatisticsVersionMismatchException; use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; +use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; -use HiEvents\Repository\Interfaces\TicketRepositoryInterface; use HiEvents\Values\MoneyValue; use Illuminate\Database\DatabaseManager; use Illuminate\Support\Carbon; @@ -26,7 +26,7 @@ { public function __construct( private PromoCodeRepositoryInterface $promoCodeRepository, - private TicketRepositoryInterface $ticketRepository, + private ProductRepositoryInterface $productRepository, private EventStatisticRepositoryInterface $eventStatisticsRepository, private EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository, private DatabaseManager $databaseManager, @@ -50,7 +50,7 @@ public function updateStatistics(OrderDomainObject $order): void $this->updateEventStats($order); $this->updateEventDailyStats($order); $this->updatePromoCodeCounts($order); - $this->updateTicketStatistics($order); + $this->updateProductStatistics($order); }); } @@ -126,12 +126,12 @@ private function updatePromoCodeCounts(OrderDomainObject $order): void } } - private function updateTicketStatistics(OrderDomainObject $order): void + private function updateProductStatistics(OrderDomainObject $order): void { foreach ($order->getOrderItems() as $orderItem) { - $this->ticketRepository->increment( - $orderItem->getTicketId(), - TicketDomainObjectAbstract::SALES_VOLUME, + $this->productRepository->increment( + $orderItem->getProductId(), + ProductDomainObjectAbstract::SALES_VOLUME, $orderItem->getTotalBeforeAdditions(), ); } @@ -153,7 +153,9 @@ private function updateEventStats(OrderDomainObject $order): void if ($eventStatistics === null) { $this->eventStatisticsRepository->create([ 'event_id' => $order->getEventId(), - 'tickets_sold' => $order->getOrderItems() + 'products_sold' => $order->getOrderItems() + ?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()), + 'attendees_registered' => $order->getTicketOrderItems() ?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()), 'sales_total_gross' => $order->getTotalGross(), 'sales_total_before_additions' => $order->getTotalBeforeAdditions(), @@ -167,7 +169,9 @@ private function updateEventStats(OrderDomainObject $order): void $update = $this->eventStatisticsRepository->updateWhere( attributes: [ - 'tickets_sold' => $eventStatistics->getTicketsSold() + $order->getOrderItems() + 'products_sold' => $eventStatistics->getProductsSold() + $order->getOrderItems() + ?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()), + 'attendees_registered' => $eventStatistics->getAttendeesRegistered() + $order->getTicketOrderItems() ?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()), 'sales_total_gross' => $eventStatistics->getSalesTotalGross() + $order->getTotalGross(), 'sales_total_before_additions' => $eventStatistics->getSalesTotalBeforeAdditions() + $order->getTotalBeforeAdditions(), @@ -208,7 +212,8 @@ private function updateEventDailyStats(OrderDomainObject $order): void $this->eventDailyStatisticRepository->create([ 'event_id' => $order->getEventId(), 'date' => (new Carbon($order->getCreatedAt()))->format('Y-m-d'), - 'tickets_sold' => $order->getOrderItems()?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()), + 'products_sold' => $order->getOrderItems()?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()), + 'attendees_registered' => $order->getTicketOrderItems()?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()), 'sales_total_gross' => $order->getTotalGross(), 'sales_total_before_additions' => $order->getTotalBeforeAdditions(), 'total_tax' => $order->getTotalTax(), @@ -220,7 +225,8 @@ private function updateEventDailyStats(OrderDomainObject $order): void $update = $this->eventDailyStatisticRepository->updateWhere( attributes: [ - 'tickets_sold' => $eventDailyStatistic->getTicketsSold() + $order->getOrderItems()->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()), + 'attendees_registered' => $eventDailyStatistic->getAttendeesRegistered() + $order->getTicketOrderItems()->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()), + 'products_sold' => $eventDailyStatistic->getProductsSold() + $order->getOrderItems()->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()), 'sales_total_gross' => $eventDailyStatistic->getSalesTotalGross() + $order->getTotalGross(), 'sales_total_before_additions' => $eventDailyStatistic->getSalesTotalBeforeAdditions() + $order->getTotalBeforeAdditions(), 'total_tax' => $eventDailyStatistic->getTotalTax() + $order->getTotalTax(), diff --git a/backend/app/Services/Domain/Invoice/InvoiceCreateService.php b/backend/app/Services/Domain/Invoice/InvoiceCreateService.php new file mode 100644 index 0000000000..fcf0d945f1 --- /dev/null +++ b/backend/app/Services/Domain/Invoice/InvoiceCreateService.php @@ -0,0 +1,80 @@ +invoiceRepository->findFirstWhere([ + 'order_id' => $orderId, + ]); + + if ($existingInvoice) { + throw new ResourceConflictException(__('Invoice already exists')); + } + + $order = $this->orderRepository + ->loadRelation(OrderItemDomainObject::class) + ->loadRelation(new Relationship(EventDomainObject::class, nested: [ + new Relationship(EventSettingDomainObject::class, name: 'event_settings'), + ], name: 'event')) + ->findById($orderId); + + /** @var EventSettingDomainObject $eventSettings */ + $eventSettings = $order->getEvent()->getEventSettings(); + /** @var EventDomainObject $event */ + $event = $order->getEvent(); + + return $this->invoiceRepository->create([ + 'order_id' => $orderId, + 'account_id' => $event->getAccountId(), + 'invoice_number' => $this->getLatestInvoiceNumber($event->getId(), $eventSettings), + 'items' => collect($order->getOrderItems())->map(fn(OrderItemDomainObject $item) => $item->toArray())->toArray(), + 'taxes_and_fees' => $order->getTaxesAndFeesRollup(), + 'issue_date' => now()->toDateString(), + 'status' => $order->isOrderCompleted() ? InvoiceStatus::PAID->name : InvoiceStatus::UNPAID->name, + 'total_amount' => $order->getTotalGross(), + 'due_date' => $eventSettings->getInvoicePaymentTermsDays() !== null + ? now()->addDays($eventSettings->getInvoicePaymentTermsDays()) + : null + ]); + } + + public function getLatestInvoiceNumber(int $eventId, EventSettingDomainObject $eventSettings): string + { + $latestInvoice = $this->invoiceRepository->findLatestInvoiceForEvent($eventId); + + $startNumber = $eventSettings->getInvoiceStartNumber() ?? 1; + $prefix = $eventSettings->getInvoicePrefix() ?? ''; + + if (!$latestInvoice) { + return $prefix . $startNumber; + } + + $nextInvoiceNumber = (int)preg_replace('/\D+/', '', $latestInvoice->getInvoiceNumber()) + 1; + + return $prefix . $nextInvoiceNumber; + } + +} diff --git a/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php b/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php index f4a6b05c85..03e997aac3 100644 --- a/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php +++ b/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php @@ -18,7 +18,7 @@ use HiEvents\Repository\Interfaces\MessageRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\UserRepositoryInterface; -use HiEvents\Services\Handlers\Message\DTO\SendMessageDTO; +use HiEvents\Services\Application\Handlers\Message\DTO\SendMessageDTO; use Illuminate\Mail\Mailer; use Illuminate\Support\Collection; use Symfony\Component\HttpKernel\Log\Logger; @@ -55,7 +55,7 @@ public function send(SendMessageDTO $messageData): void 'event_id' => $messageData->event_id, ]); - if ((!$order && $messageData->type === MessageTypeEnum::ORDER) || !$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); @@ -64,18 +64,21 @@ public function send(SendMessageDTO $messageData): void } switch ($messageData->type) { - case MessageTypeEnum::ATTENDEE: + case MessageTypeEnum::INDIVIDUAL_ATTENDEES: $this->sendAttendeeMessages($messageData, $event); break; - case MessageTypeEnum::ORDER: + case MessageTypeEnum::ORDER_OWNER: $this->sendOrderMessages($messageData, $event, $order); break; - case MessageTypeEnum::TICKET: - $this->sendTicketMessages($messageData, $event); + case MessageTypeEnum::TICKET_HOLDERS: + $this->sendTicketHolderMessages($messageData, $event); break; - case MessageTypeEnum::EVENT: + case MessageTypeEnum::ALL_ATTENDEES: $this->sendEventMessages($messageData, $event); break; + case MessageTypeEnum::ORDER_OWNERS_WITH_PRODUCT: + $this->sendProductMessages($messageData, $event); + break; } $this->updateMessageStatus($messageData, MessageStatus::SENT); @@ -95,11 +98,11 @@ private function sendAttendeeMessages(SendMessageDTO $messageData, EventDomainOb $this->emailAttendees($attendees, $messageData, $event); } - private function sendTicketMessages(SendMessageDTO $messageData, EventDomainObject $event): void + private function sendTicketHolderMessages(SendMessageDTO $messageData, EventDomainObject $event): void { $attendees = $this->attendeeRepository->findWhereIn( - field: 'ticket_id', - values: $messageData->ticket_ids, + field: 'product_id', + values: $messageData->product_ids, additionalWhere: [ 'event_id' => $messageData->event_id, 'status' => AttendeeStatus::ACTIVE->name, @@ -120,7 +123,7 @@ private function sendOrderMessages( $this->sendMessage( emailAddress: $order->getEmail(), - fullName: $order->getFirstName() . ' ' . $order->getLastName(), + fullName: $order->getFullName(), messageData: $messageData, event: $event, ); @@ -216,4 +219,28 @@ private function sendMessage( messageData: $messageData )); } + + private function sendProductMessages(SendMessageDTO $messageData, EventDomainObject $event): void + { + $orders = $this->orderRepository->findOrdersAssociatedWithProducts( + eventId: $messageData->event_id, + productIds: $messageData->product_ids, + orderStatuses: $messageData->order_statuses + ); + + if ($orders->isEmpty()) { + return; + } + + $this->sendEmailToMessageSender($messageData, $event); + + $orders->each(function (OrderDomainObject $order) use ($messageData, $event) { + $this->sendMessage( + emailAddress: $order->getEmail(), + fullName: $order->getFullName(), + messageData: $messageData, + event: $event, + ); + }); + } } diff --git a/backend/app/Services/Domain/Mail/SendOrderDetailsService.php b/backend/app/Services/Domain/Mail/SendOrderDetailsService.php index 62040573f9..a4e63b66f9 100644 --- a/backend/app/Services/Domain/Mail/SendOrderDetailsService.php +++ b/backend/app/Services/Domain/Mail/SendOrderDetailsService.php @@ -5,6 +5,7 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; +use HiEvents\DomainObjects\InvoiceDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; @@ -28,11 +29,12 @@ public function __construct( { } - public function sendOrderSummaryAndTicketEmails(OrderDomainObject $order): void + public function sendOrderSummaryAndProductEmails(OrderDomainObject $order): void { $order = $this->orderRepository ->loadRelation(OrderItemDomainObject::class) ->loadRelation(AttendeeDomainObject::class) + ->loadRelation(InvoiceDomainObject::class) ->findById($order->getId()); $event = $this->eventRepository @@ -40,7 +42,7 @@ public function sendOrderSummaryAndTicketEmails(OrderDomainObject $order): void ->loadRelation(new Relationship(EventSettingDomainObject::class)) ->findById($order->getEventId()); - if ($order->isOrderCompleted()) { + if ($order->isOrderCompleted() || $order->isOrderAwaitingOfflinePayment()) { $this->sendOrderSummaryEmails($order, $event); $this->sendAttendeeTicketEmails($order, $event); } @@ -66,6 +68,7 @@ private function sendAttendeeTicketEmails(OrderDomainObject $order, EventDomainO } $this->sendAttendeeTicketService->send( + order: $order, attendee: $attendee, event: $event, eventSettings: $event->getEventSettings(), @@ -86,6 +89,7 @@ private function sendOrderSummaryEmails(OrderDomainObject $order, EventDomainObj event: $event, organizer: $event->getOrganizer(), eventSettings: $event->getEventSettings(), + invoice: $order->getLatestInvoice(), )); if ($order->getIsManuallyCreated() || !$event->getEventSettings()->getNotifyOrganizerOfNewOrders()) { diff --git a/backend/app/Services/Domain/Order/DTO/InvoicePdfResponseDTO.php b/backend/app/Services/Domain/Order/DTO/InvoicePdfResponseDTO.php new file mode 100644 index 0000000000..0a8d95a672 --- /dev/null +++ b/backend/app/Services/Domain/Order/DTO/InvoicePdfResponseDTO.php @@ -0,0 +1,15 @@ +databaseManager->transaction(function () use ($id, $first_name, $last_name, $email, $notes) { + $this->orderRepository->updateWhere( + attributes: array_filter([ + 'first_name' => $first_name, + 'last_name' => $last_name, + 'email' => $email, + 'notes' => $notes, + ]), + where: [ + 'id' => $id + ] + ); + + $this->webhookDispatchService->queueOrderWebhook( + eventType: WebhookEventType::ORDER_UPDATED, + orderId: $id, + ); + + return $this->orderRepository->findById($id); + }); + } +} diff --git a/backend/app/Services/Domain/Order/GenerateOrderInvoicePDFService.php b/backend/app/Services/Domain/Order/GenerateOrderInvoicePDFService.php new file mode 100644 index 0000000000..2073c609a6 --- /dev/null +++ b/backend/app/Services/Domain/Order/GenerateOrderInvoicePDFService.php @@ -0,0 +1,70 @@ +generatePdf([ + 'short_id' => $orderShortId, + 'event_id' => $eventId, + ]); + } + + public function generatePdfFromOrderId(int $orderId, int $eventId): InvoicePdfResponseDTO + { + return $this->generatePdf([ + 'id' => $orderId, + 'event_id' => $eventId, + ]); + } + + private function generatePdf(array $whereCriteria): InvoicePdfResponseDTO + { + $order = $this->orderRepository + ->loadRelation(new Relationship(EventDomainObject::class, nested: [ + new Relationship(OrganizerDomainObject::class, name: 'organizer'), + new Relationship(EventSettingDomainObject::class, name: 'event_settings'), + ], name: 'event')) + ->findFirstWhere($whereCriteria); + + if (!$order) { + throw new ResourceNotFoundException(__('Order not found')); + } + + $invoice = $this->invoiceRepository->findLatestInvoiceForOrder($order->getId()); + + if (!$invoice) { + throw new ResourceNotFoundException(__('Invoice not found')); + } + + return new InvoicePdfResponseDTO( + pdf: Pdf::loadView('invoice', [ + 'order' => $order, + 'event' => $order->getEvent(), + 'organizer' => $order->getEvent()->getOrganizer(), + 'eventSettings' => $order->getEvent()->getEventSettings(), + 'invoice' => $invoice, + ]), + filename: $invoice->getInvoiceNumber() . '.pdf' + ); + } +} diff --git a/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php b/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php new file mode 100644 index 0000000000..e9dfd36f4a --- /dev/null +++ b/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php @@ -0,0 +1,145 @@ +databaseManager->transaction(function () use ($orderId, $eventId) { + /** @var OrderDomainObject $order */ + $order = $this->orderRepository->findFirstWhere([ + OrderDomainObjectAbstract::ID => $orderId, + OrderDomainObjectAbstract::EVENT_ID => $eventId, + ]); + + if ($order->getStatus() !== OrderStatus::AWAITING_OFFLINE_PAYMENT->name) { + throw new ResourceConflictException(__('Order is not awaiting offline payment')); + } + + $this->updateOrderStatus($orderId); + + $this->updateOrderInvoice($orderId); + + $updatedOrder = $this->orderRepository->findById($orderId); + + $this->updateAttendeeStatuses($updatedOrder); + + event(new OrderStatusChangedEvent( + order: $updatedOrder, + sendEmails: false + )); + + $this->webhookDispatchService->queueOrderWebhook( + eventType: WebhookEventType::ORDER_MARKED_AS_PAID, + orderId: $orderId, + ); + + $this->storeApplicationFeePayment($updatedOrder); + + return $updatedOrder; + }); + } + + private function updateOrderInvoice(int $orderId): void + { + $invoice = $this->invoiceRepository->findLatestInvoiceForOrder($orderId); + + if ($invoice) { + $this->invoiceRepository->updateFromArray($invoice->getId(), [ + 'status' => InvoiceStatus::PAID->name, + ]); + } + } + + private function updateOrderStatus(int $orderId): void + { + $this->orderRepository->updateFromArray($orderId, [ + OrderDomainObjectAbstract::STATUS => OrderStatus::COMPLETED->name, + OrderDomainObjectAbstract::PAYMENT_STATUS => OrderPaymentStatus::PAYMENT_RECEIVED->name, + ]); + } + + private function updateAttendeeStatuses(OrderDomainObject $updatedOrder): void + { + $this->attendeeRepository->updateWhere( + attributes: [ + 'status' => AttendeeStatus::ACTIVE->name, + ], + where: [ + 'order_id' => $updatedOrder->getId(), + 'status' => AttendeeStatus::AWAITING_PAYMENT->name, + ], + ); + } + + private function storeApplicationFeePayment(OrderDomainObject $updatedOrder): void + { + /** @var AccountConfigurationDomainObject $config */ + $config = $this->eventRepository + ->loadRelation(new Relationship( + domainObject: AccountDomainObject::class, + nested: [ + new Relationship( + domainObject: AccountConfigurationDomainObject::class, + name: 'configuration', + ), + ], + name: 'account' + )) + ->findById($updatedOrder->getEventId()) + ->getAccount() + ->getConfiguration(); + + $this->orderApplicationFeeService->createOrderApplicationFee( + orderId: $updatedOrder->getId(), + applicationFeeAmount: $this->orderApplicationFeeCalculationService->calculateApplicationFee( + $config, + $updatedOrder->getTotalGross(), + ), + orderApplicationFeeStatus: OrderApplicationFeeStatus::AWAITING_PAYMENT, + paymentMethod: PaymentProviders::OFFLINE, + ); + } +} diff --git a/backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php b/backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php new file mode 100644 index 0000000000..1bb69c18f6 --- /dev/null +++ b/backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php @@ -0,0 +1,30 @@ +config->get('app.saas_mode_enabled')) { + return 0; + } + + $fixedFee = $accountConfiguration->getFixedApplicationFee(); + $percentageFee = $accountConfiguration->getPercentageApplicationFee(); + + return ($fixedFee) + ($orderTotal * ($percentageFee / 100)); + } +} diff --git a/backend/app/Services/Domain/Order/OrderApplicationFeeService.php b/backend/app/Services/Domain/Order/OrderApplicationFeeService.php new file mode 100644 index 0000000000..889bff2503 --- /dev/null +++ b/backend/app/Services/Domain/Order/OrderApplicationFeeService.php @@ -0,0 +1,35 @@ +orderApplicationFeeRepository->create([ + OrderApplicationFeeDomainObjectAbstract::ORDER_ID => $orderId, + OrderApplicationFeeDomainObjectAbstract::AMOUNT => $applicationFeeAmount, + OrderApplicationFeeDomainObjectAbstract::STATUS => $orderApplicationFeeStatus->value, + OrderApplicationFeeDomainObjectAbstract::PAYMENT_METHOD => $paymentMethod->value, + OrderApplicationFeeDomainObjectAbstract::PAID_AT => $orderApplicationFeeStatus->value === OrderApplicationFeeStatus::PAID->value + ? now() + : null, + ]); + } +} diff --git a/backend/app/Services/Domain/Order/OrderCancelService.php b/backend/app/Services/Domain/Order/OrderCancelService.php index e439f26df0..31ffbc3058 100644 --- a/backend/app/Services/Domain/Order/OrderCancelService.php +++ b/backend/app/Services/Domain/Order/OrderCancelService.php @@ -3,6 +3,7 @@ namespace HiEvents\Services\Domain\Order; use HiEvents\DomainObjects\AttendeeDomainObject; +use HiEvents\DomainObjects\Enums\WebhookEventType; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\Status\AttendeeStatus; @@ -11,20 +12,22 @@ use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; -use HiEvents\Services\Domain\Ticket\TicketQuantityUpdateService; +use HiEvents\Services\Domain\Product\ProductQuantityUpdateService; +use HiEvents\Services\Infrastructure\Webhook\WebhookDispatchService; use Illuminate\Contracts\Mail\Mailer; use Illuminate\Database\DatabaseManager; use Throwable; -readonly class OrderCancelService +class OrderCancelService { public function __construct( - private Mailer $mailer, - private AttendeeRepositoryInterface $attendeeRepository, - private EventRepositoryInterface $eventRepository, - private OrderRepositoryInterface $orderRepository, - private DatabaseManager $databaseManager, - private TicketQuantityUpdateService $ticketQuantityService, + private readonly Mailer $mailer, + private readonly AttendeeRepositoryInterface $attendeeRepository, + private readonly EventRepositoryInterface $eventRepository, + private readonly OrderRepositoryInterface $orderRepository, + private readonly DatabaseManager $databaseManager, + private readonly ProductQuantityUpdateService $productQuantityService, + private readonly WebhookDispatchService $webhookDispatchService, ) { } @@ -35,7 +38,7 @@ public function __construct( public function cancelOrder(OrderDomainObject $order): void { $this->databaseManager->transaction(function () use ($order) { - $this->adjustTicketQuantities($order); + $this->adjustProductQuantities($order); $this->cancelAttendees($order); $this->updateOrderStatus($order); @@ -51,6 +54,11 @@ public function cancelOrder(OrderDomainObject $order): void event: $event, eventSettings: $event->getEventSettings(), )); + + $this->webhookDispatchService->queueOrderWebhook( + eventType: WebhookEventType::ORDER_CANCELLED, + orderId: $order->getId(), + ); }); } @@ -66,18 +74,18 @@ private function cancelAttendees(OrderDomainObject $order): void ); } - private function adjustTicketQuantities(OrderDomainObject $order): void + private function adjustProductQuantities(OrderDomainObject $order): void { $attendees = $this->attendeeRepository->findWhere([ 'order_id' => $order->getId(), 'status' => AttendeeStatus::ACTIVE->name, ]); - $ticketIdCountMap = $attendees - ->map(fn(AttendeeDomainObject $attendee) => $attendee->getTicketPriceId())->countBy(); + $productIdCountMap = $attendees + ->map(fn(AttendeeDomainObject $attendee) => $attendee->getProductPriceId())->countBy(); - foreach ($ticketIdCountMap as $ticketPriceId => $count) { - $this->ticketQuantityService->decreaseQuantitySold($ticketPriceId, $count); + foreach ($productIdCountMap as $productPriceId => $count) { + $this->productQuantityService->decreaseQuantitySold($productPriceId, $count); } } diff --git a/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php b/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php index b55ad8c76e..f075f65b98 100644 --- a/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php +++ b/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php @@ -4,18 +4,18 @@ use Exception; use HiEvents\DomainObjects\CapacityAssignmentDomainObject; -use HiEvents\DomainObjects\Enums\TicketType; +use HiEvents\DomainObjects\Enums\ProductPriceType; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\Generated\PromoCodeDomainObjectAbstract; -use HiEvents\DomainObjects\TicketDomainObject; -use HiEvents\DomainObjects\TicketPriceDomainObject; +use HiEvents\DomainObjects\ProductDomainObject; +use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\Helper\Currency; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; -use HiEvents\Repository\Interfaces\TicketRepositoryInterface; -use HiEvents\Services\Domain\Ticket\AvailableTicketQuantitiesFetchService; -use HiEvents\Services\Domain\Ticket\DTO\AvailableTicketQuantitiesDTO; -use HiEvents\Services\Domain\Ticket\DTO\AvailableTicketQuantitiesResponseDTO; +use HiEvents\Repository\Interfaces\ProductRepositoryInterface; +use HiEvents\Services\Domain\Product\AvailableProductQuantitiesFetchService; +use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesDTO; +use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesResponseDTO; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; @@ -23,13 +23,13 @@ class OrderCreateRequestValidationService { - private AvailableTicketQuantitiesResponseDTO $availableTicketQuantities; + private AvailableProductQuantitiesResponseDTO $availableProductQuantities; public function __construct( - readonly private TicketRepositoryInterface $ticketRepository, - readonly private PromoCodeRepositoryInterface $promoCodeRepository, - readonly private EventRepositoryInterface $eventRepository, - readonly private AvailableTicketQuantitiesFetchService $fetchAvailableTicketQuantitiesService, + readonly private ProductRepositoryInterface $productRepository, + readonly private PromoCodeRepositoryInterface $promoCodeRepository, + readonly private EventRepositoryInterface $eventRepository, + readonly private AvailableProductQuantitiesFetchService $fetchAvailableProductQuantitiesService, ) { } @@ -44,16 +44,16 @@ public function validateRequestData(int $eventId, array $data = []): void $event = $this->eventRepository->findById($eventId); $this->validatePromoCode($eventId, $data); - $this->validateTicketSelection($data); + $this->validateProductSelection($data); - $this->availableTicketQuantities = $this->fetchAvailableTicketQuantitiesService - ->getAvailableTicketQuantities( + $this->availableProductQuantities = $this->fetchAvailableProductQuantitiesService + ->getAvailableProductQuantities( $event->getId(), ignoreCache: true, ); $this->validateOverallCapacity($data); - $this->validateTicketDetails($event, $data); + $this->validateProductDetails($event, $data); } /** @@ -81,12 +81,12 @@ private function validatePromoCode(int $eventId, array $data): void private function validateTypes(array $data): void { $validator = Validator::make($data, [ - 'tickets' => 'required|array', - 'tickets.*.ticket_id' => 'required|integer', - 'tickets.*.quantities' => 'required|array', - 'tickets.*.quantities.*.quantity' => 'required|integer', - 'tickets.*.quantities.*.price_id' => 'required|integer', - 'tickets.*.quantities.*.price' => 'numeric|min:0', + 'products' => 'required|array', + 'products.*.product_id' => 'required|integer', + 'products.*.quantities' => 'required|array', + 'products.*.quantities.*.quantity' => 'required|integer', + 'products.*.quantities.*.price_id' => 'required|integer', + 'products.*.quantities.*.price' => 'numeric|min:0', ]); if ($validator->fails()) { @@ -97,12 +97,12 @@ private function validateTypes(array $data): void /** * @throws ValidationException */ - private function validateTicketSelection(array $data): void + private function validateProductSelection(array $data): void { - $ticketData = collect($data['tickets']); - if ($ticketData->isEmpty() || $ticketData->sum(fn($ticket) => collect($ticket['quantities'])->sum('quantity')) === 0) { + $productData = collect($data['products']); + if ($productData->isEmpty() || $productData->sum(fn($product) => collect($product['quantities'])->sum('quantity')) === 0) { throw ValidationException::withMessages([ - 'tickets' => __('You haven\'t selected any tickets') + 'products' => __('You haven\'t selected any products') ]); } } @@ -110,150 +110,150 @@ private function validateTicketSelection(array $data): void /** * @throws Exception */ - private function getTickets(array $data): Collection + private function getProducts(array $data): Collection { - $ticketIds = collect($data['tickets'])->pluck('ticket_id'); - return $this->ticketRepository - ->loadRelation(TicketPriceDomainObject::class) - ->findWhereIn('id', $ticketIds->toArray()); + $productIds = collect($data['products'])->pluck('product_id'); + return $this->productRepository + ->loadRelation(ProductPriceDomainObject::class) + ->findWhereIn('id', $productIds->toArray()); } /** * @throws ValidationException * @throws Exception */ - private function validateTicketDetails(EventDomainObject $event, array $data): void + private function validateProductDetails(EventDomainObject $event, array $data): void { - $tickets = $this->getTickets($data); + $products = $this->getProducts($data); - foreach ($data['tickets'] as $ticketIndex => $ticketAndQuantities) { - $this->validateSingleTicketDetails($event, $ticketIndex, $ticketAndQuantities, $tickets); + foreach ($data['products'] as $productIndex => $productAndQuantities) { + $this->validateSingleProductDetails($event, $productIndex, $productAndQuantities, $products); } } /** * @throws ValidationException */ - private function validateSingleTicketDetails(EventDomainObject $event, int $ticketIndex, array $ticketAndQuantities, $tickets): void + private function validateSingleProductDetails(EventDomainObject $event, int $productIndex, array $productAndQuantities, $products): void { - $ticketId = $ticketAndQuantities['ticket_id']; - $totalQuantity = collect($ticketAndQuantities['quantities'])->sum('quantity'); + $productId = $productAndQuantities['product_id']; + $totalQuantity = collect($productAndQuantities['quantities'])->sum('quantity'); if ($totalQuantity === 0) { return; } - /** @var TicketDomainObject $ticket */ - $ticket = $tickets->filter(fn($t) => $t->getId() === $ticketId)->first(); - if (!$ticket) { - throw new NotFoundHttpException(sprintf('Ticket ID %d not found', $ticketId)); + /** @var ProductDomainObject $product */ + $product = $products->filter(fn($t) => $t->getId() === $productId)->first(); + if (!$product) { + throw new NotFoundHttpException(sprintf('Product ID %d not found', $productId)); } - $this->validateTicketEvent( + $this->validateProductEvent( event: $event, - ticketId: $ticketId, - ticket: $ticket + productId: $productId, + product: $product ); - $this->validateTicketQuantity( - ticketIndex: $ticketIndex, - ticketAndQuantities: $ticketAndQuantities, - ticket: $ticket + $this->validateProductQuantity( + productIndex: $productIndex, + productAndQuantities: $productAndQuantities, + product: $product ); - $this->validateTicketTypeAndPrice( + $this->validateProductTypeAndPrice( event: $event, - ticketIndex: $ticketIndex, - ticketAndQuantities: $ticketAndQuantities, - ticket: $ticket + productIndex: $productIndex, + productAndQuantities: $productAndQuantities, + product: $product ); - $this->validateSoldOutTickets( - ticketId: $ticketId, - ticketIndex: $ticketIndex, - ticket: $ticket + $this->validateSoldOutProducts( + productId: $productId, + productIndex: $productIndex, + product: $product ); $this->validatePriceIdAndQuantity( - ticketIndex: $ticketIndex, - ticketAndQuantities: $ticketAndQuantities, - ticket: $ticket + productIndex: $productIndex, + productAndQuantities: $productAndQuantities, + product: $product ); } /** * @throws ValidationException */ - private function validateTicketQuantity(int $ticketIndex, array $ticketAndQuantities, TicketDomainObject $ticket): void + private function validateProductQuantity(int $productIndex, array $productAndQuantities, ProductDomainObject $product): void { - $totalQuantity = collect($ticketAndQuantities['quantities'])->sum('quantity'); - $maxPerOrder = (int)$ticket->getMaxPerOrder() ?: 100; + $totalQuantity = collect($productAndQuantities['quantities'])->sum('quantity'); + $maxPerOrder = (int)$product->getMaxPerOrder() ?: 100; - $capacityMaximum = $this->availableTicketQuantities - ->ticketQuantities - ->where('ticket_id', $ticket->getId()) - ->map(fn(AvailableTicketQuantitiesDTO $price) => $price->capacities) + $capacityMaximum = $this->availableProductQuantities + ->productQuantities + ->where('product_id', $product->getId()) + ->map(fn(AvailableProductQuantitiesDTO $price) => $price->capacities) ->flatten() ->min(fn(CapacityAssignmentDomainObject $capacity) => $capacity->getCapacity()); - $ticketAvailableQuantity = $this->availableTicketQuantities - ->ticketQuantities - ->first(fn(AvailableTicketQuantitiesDTO $price) => $price->ticket_id === $ticket->getId()) + $productAvailableQuantity = $this->availableProductQuantities + ->productQuantities + ->first(fn(AvailableProductQuantitiesDTO $price) => $price->product_id === $product->getId()) ->quantity_available; - # if there are fewer tickets available than the configured minimum, we allow less than the minimum to be purchased - $minPerOrder = min((int)$ticket->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, - $ticketAvailableQuantity ?: $maxPerOrder); + $productAvailableQuantity ?: $maxPerOrder); - $this->validateTicketPricesQuantity( - quantities: $ticketAndQuantities['quantities'], - ticket: $ticket, - ticketIndex: $ticketIndex + $this->validateProductPricesQuantity( + quantities: $productAndQuantities['quantities'], + product: $product, + productIndex: $productIndex ); if ($totalQuantity > $maxPerOrder) { throw ValidationException::withMessages([ - "tickets.$ticketIndex" => __("The maximum number of tickets available for :tickets is :max", [ + "products.$productIndex" => __("The maximum number of products available for :products is :max", [ 'max' => $maxPerOrder, - 'ticket' => $ticket->getTitle(), + 'product' => $product->getTitle(), ]), ]); } if ($totalQuantity < $minPerOrder) { throw ValidationException::withMessages([ - "tickets.$ticketIndex" => __("You must order at least :min tickets for :ticket", [ + "products.$productIndex" => __("You must order at least :min products for :product", [ 'min' => $minPerOrder, - 'ticket' => $ticket->getTitle(), + 'product' => $product->getTitle(), ]), ]); } } - private function validateTicketEvent(EventDomainObject $event, int $ticketId, TicketDomainObject $ticket): void + private function validateProductEvent(EventDomainObject $event, int $productId, ProductDomainObject $product): void { - if ($ticket->getEventId() !== $event->getId()) { - throw new NotFoundHttpException(sprintf('Ticket ID %d not found for event ID %d', $ticketId, $event->getId())); + if ($product->getEventId() !== $event->getId()) { + throw new NotFoundHttpException(sprintf('Product ID %d not found for event ID %d', $productId, $event->getId())); } } /** * @throws ValidationException */ - private function validateTicketTypeAndPrice( + private function validateProductTypeAndPrice( EventDomainObject $event, - int $ticketIndex, - array $ticketAndQuantities, - TicketDomainObject $ticket + int $productIndex, + array $productAndQuantities, + ProductDomainObject $product ): void { - if ($ticket->getType() === TicketType::DONATION->name) { - $price = $ticketAndQuantities['quantities'][0]['price'] ?? 0; - if ($price < $ticket->getPrice()) { - $formattedPrice = Currency::format($ticket->getPrice(), $event->getCurrency()); + 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([ - "tickets.$ticketIndex.quantities.0.price" => __("The minimum amount is :price", ['price' => $formattedPrice]), + "products.$productIndex.quantities.0.price" => __("The minimum amount is :price", ['price' => $formattedPrice]), ]); } } @@ -262,13 +262,13 @@ private function validateTicketTypeAndPrice( /** * @throws ValidationException */ - private function validateSoldOutTickets(int $ticketId, int $ticketIndex, TicketDomainObject $ticket): void + private function validateSoldOutProducts(int $productId, int $productIndex, ProductDomainObject $product): void { - if ($ticket->isSoldOut()) { + if ($product->isSoldOut()) { throw ValidationException::withMessages([ - "tickets.$ticketIndex" => __("The ticket :ticket is sold out", [ - 'id' => $ticketId, - 'ticket' => $ticket->getTitle(), + "products.$productIndex" => __("The product :product is sold out", [ + 'id' => $productId, + 'product' => $product->getTitle(), ]), ]); } @@ -277,24 +277,24 @@ private function validateSoldOutTickets(int $ticketId, int $ticketIndex, TicketD /** * @throws ValidationException */ - private function validatePriceIdAndQuantity(int $ticketIndex, array $ticketAndQuantities, TicketDomainObject $ticket): void + private function validatePriceIdAndQuantity(int $productIndex, array $productAndQuantities, ProductDomainObject $product): void { $errors = []; - foreach ($ticketAndQuantities['quantities'] as $quantityIndex => $quantityData) { + foreach ($productAndQuantities['quantities'] as $quantityIndex => $quantityData) { $priceId = $quantityData['price_id'] ?? null; $quantity = $quantityData['quantity'] ?? null; if (null === $priceId || null === $quantity) { $missingField = null === $priceId ? 'price_id' : 'quantity'; - $errors["tickets.$ticketIndex.quantities.$quantityIndex.$missingField"] = __(":field must be specified", [ + $errors["products.$productIndex.quantities.$quantityIndex.$missingField"] = __(":field must be specified", [ 'field' => ucfirst($missingField) ]); } - $validPriceIds = $ticket->getTicketPrices()?->map(fn(TicketPriceDomainObject $price) => $price->getId()); + $validPriceIds = $product->getProductPrices()?->map(fn(ProductPriceDomainObject $price) => $price->getId()); if (!in_array($priceId, $validPriceIds->toArray(), true)) { - $errors["tickets.$ticketIndex.quantities.$quantityIndex.price_id"] = __('Invalid price ID'); + $errors["products.$productIndex.quantities.$quantityIndex.price_id"] = __('Invalid price ID'); } } @@ -306,32 +306,32 @@ private function validatePriceIdAndQuantity(int $ticketIndex, array $ticketAndQu /** * @throws ValidationException */ - private function validateTicketPricesQuantity(array $quantities, TicketDomainObject $ticket, int $ticketIndex): void + private function validateProductPricesQuantity(array $quantities, ProductDomainObject $product, int $productIndex): void { - foreach ($quantities as $ticketQuantity) { - $numberAvailable = $this->availableTicketQuantities - ->ticketQuantities - ->where('ticket_id', $ticket->getId()) - ->where('price_id', $ticketQuantity['price_id']) + foreach ($quantities as $productQuantity) { + $numberAvailable = $this->availableProductQuantities + ->productQuantities + ->where('product_id', $product->getId()) + ->where('price_id', $productQuantity['price_id']) ->first()?->quantity_available; - /** @var TicketPriceDomainObject $ticketPrice */ - $ticketPrice = $ticket->getTicketPrices() - ?->first(fn(TicketPriceDomainObject $price) => $price->getId() === $ticketQuantity['price_id']); + /** @var ProductPriceDomainObject $productPrice */ + $productPrice = $product->getProductPrices() + ?->first(fn(ProductPriceDomainObject $price) => $price->getId() === $productQuantity['price_id']); - if ($ticketQuantity['quantity'] > $numberAvailable) { + if ($productQuantity['quantity'] > $numberAvailable) { if ($numberAvailable === 0) { throw ValidationException::withMessages([ - "tickets.$ticketIndex" => __("The ticket :ticket is sold out", [ - 'ticket' => $ticket->getTitle() . ($ticketPrice->getLabel() ? ' - ' . $ticketPrice->getLabel() : ''), + "products.$productIndex" => __("The product :product is sold out", [ + 'product' => $product->getTitle() . ($productPrice->getLabel() ? ' - ' . $productPrice->getLabel() : ''), ]), ]); } throw ValidationException::withMessages([ - "tickets.$ticketIndex" => __("The maximum number of tickets available for :ticket is :max", [ + "products.$productIndex" => __("The maximum number of products available for :product is :max", [ 'max' => $numberAvailable, - 'ticket' => $ticket->getTitle() . ($ticketPrice->getLabel() ? ' - ' . $ticketPrice->getLabel() : ''), + 'product' => $product->getTitle() . ($productPrice->getLabel() ? ' - ' . $productPrice->getLabel() : ''), ]), ]); } @@ -343,34 +343,34 @@ private function validateTicketPricesQuantity(array $quantities, TicketDomainObj */ private function validateOverallCapacity(array $data): void { - foreach ($this->availableTicketQuantities->capacities as $capacity) { - if ($capacity->getTickets() === null) { + foreach ($this->availableProductQuantities->capacities as $capacity) { + if ($capacity->getProducts() === null) { continue; } - $ticketIds = $capacity->getTickets()->map(fn(TicketDomainObject $ticket) => $ticket->getId()); - $totalQuantity = collect($data['tickets']) - ->filter(fn($ticket) => in_array($ticket['ticket_id'], $ticketIds->toArray(), true)) - ->sum(fn($ticket) => collect($ticket['quantities'])->sum('quantity')); + $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')); - $reservedTicketQuantities = $capacity->getTickets() - ->map(fn(TicketDomainObject $ticket) => $this - ->availableTicketQuantities - ->ticketQuantities - ->where('ticket_id', $ticket->getId()) + $reservedProductQuantities = $capacity->getProducts() + ->map(fn(ProductDomainObject $product) => $this + ->availableProductQuantities + ->productQuantities + ->where('product_id', $product->getId()) ->sum('quantity_reserved') ) ->sum(); - if ($totalQuantity > ($capacity->getAvailableCapacity() - $reservedTicketQuantities)) { - if ($capacity->getAvailableCapacity() - $reservedTicketQuantities <= 0) { + if ($totalQuantity > ($capacity->getAvailableCapacity() - $reservedProductQuantities)) { + if ($capacity->getAvailableCapacity() - $reservedProductQuantities <= 0) { throw ValidationException::withMessages([ - 'tickets' => __('Sorry, these tickets are sold out'), + 'products' => __('Sorry, these products are sold out'), ]); } throw ValidationException::withMessages([ - 'tickets' => __('The maximum number of tickets available is :max', [ + 'products' => __('The maximum number of products available is :max', [ 'max' => $capacity->getAvailableCapacity(), ]), ]); diff --git a/backend/app/Services/Domain/Order/OrderItemProcessingService.php b/backend/app/Services/Domain/Order/OrderItemProcessingService.php index 3ab93a6e7b..f0b784b556 100644 --- a/backend/app/Services/Domain/Order/OrderItemProcessingService.php +++ b/backend/app/Services/Domain/Order/OrderItemProcessingService.php @@ -3,19 +3,19 @@ namespace HiEvents\Services\Domain\Order; use HiEvents\DomainObjects\EventDomainObject; -use HiEvents\DomainObjects\Generated\TicketDomainObjectAbstract; +use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract; use HiEvents\DomainObjects\OrderDomainObject; +use HiEvents\DomainObjects\ProductDomainObject; +use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\DomainObjects\PromoCodeDomainObject; use HiEvents\DomainObjects\TaxAndFeesDomainObject; -use HiEvents\DomainObjects\TicketDomainObject; -use HiEvents\DomainObjects\TicketPriceDomainObject; use HiEvents\Helper\Currency; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; -use HiEvents\Repository\Interfaces\TicketRepositoryInterface; +use HiEvents\Repository\Interfaces\ProductRepositoryInterface; +use HiEvents\Services\Application\Handlers\Order\DTO\ProductOrderDetailsDTO; +use HiEvents\Services\Domain\Product\DTO\OrderProductPriceDTO; +use HiEvents\Services\Domain\Product\ProductPriceService; use HiEvents\Services\Domain\Tax\TaxAndFeeCalculationService; -use HiEvents\Services\Domain\Ticket\DTO\OrderTicketPriceDTO; -use HiEvents\Services\Domain\Ticket\TicketPriceService; -use HiEvents\Services\Handlers\Order\DTO\TicketOrderDetailsDTO; use Illuminate\Support\Collection; use Symfony\Component\Routing\Exception\ResourceNotFoundException; @@ -23,49 +23,49 @@ { public function __construct( private OrderRepositoryInterface $orderRepository, - private TicketRepositoryInterface $ticketRepository, + private ProductRepositoryInterface $productRepository, private TaxAndFeeCalculationService $taxCalculationService, - private TicketPriceService $ticketPriceService, + private ProductPriceService $productPriceService, ) { } /** * @param OrderDomainObject $order - * @param Collection $ticketsOrderDetails + * @param Collection $productsOrderDetails * @param EventDomainObject $event * @param PromoCodeDomainObject|null $promoCode * @return Collection */ public function process( OrderDomainObject $order, - Collection $ticketsOrderDetails, + Collection $productsOrderDetails, EventDomainObject $event, ?PromoCodeDomainObject $promoCode ): Collection { $orderItems = collect(); - foreach ($ticketsOrderDetails as $ticketOrderDetail) { - $ticket = $this->ticketRepository + foreach ($productsOrderDetails as $productOrderDetail) { + $product = $this->productRepository ->loadRelation(TaxAndFeesDomainObject::class) - ->loadRelation(TicketPriceDomainObject::class) + ->loadRelation(ProductPriceDomainObject::class) ->findFirstWhere([ - TicketDomainObjectAbstract::ID => $ticketOrderDetail->ticket_id, - TicketDomainObjectAbstract::EVENT_ID => $event->getId(), + ProductDomainObjectAbstract::ID => $productOrderDetail->product_id, + ProductDomainObjectAbstract::EVENT_ID => $event->getId(), ]); - if ($ticket === null) { + if ($product === null) { throw new ResourceNotFoundException( - __('Ticket with id :id not found', ['id' => $ticketOrderDetail->ticket_id]) + __('Product with id :id not found', ['id' => $productOrderDetail->product_id]) ); } - $ticketOrderDetail->quantities->each(function (OrderTicketPriceDTO $ticketPrice) use ($promoCode, $order, $orderItems, $ticket) { - if ($ticketPrice->quantity === 0) { + $productOrderDetail->quantities->each(function (OrderProductPriceDTO $productPrice) use ($promoCode, $order, $orderItems, $product) { + if ($productPrice->quantity === 0) { return; } - $orderItemData = $this->calculateOrderItemData($ticket, $ticketPrice, $order, $promoCode); + $orderItemData = $this->calculateOrderItemData($product, $productPrice, $order, $promoCode); $orderItems->push($this->orderRepository->addOrderItem($orderItemData)); }); } @@ -74,33 +74,34 @@ public function process( } private function calculateOrderItemData( - TicketDomainObject $ticket, - OrderTicketPriceDTO $ticketPriceDetails, + ProductDomainObject $product, + OrderProductPriceDTO $productPriceDetails, OrderDomainObject $order, ?PromoCodeDomainObject $promoCode ): array { - $prices = $this->ticketPriceService->getPrice($ticket, $ticketPriceDetails, $promoCode); + $prices = $this->productPriceService->getPrice($product, $productPriceDetails, $promoCode); $priceWithDiscount = $prices->price; $priceBeforeDiscount = $prices->price_before_discount; - $itemTotalWithDiscount = $priceWithDiscount * $ticketPriceDetails->quantity; + $itemTotalWithDiscount = $priceWithDiscount * $productPriceDetails->quantity; - $taxesAndFees = $this->taxCalculationService->calculateTaxAndFeesForTicket( - ticket: $ticket, + $taxesAndFees = $this->taxCalculationService->calculateTaxAndFeesForProduct( + product: $product, price: $priceWithDiscount, - quantity: $ticketPriceDetails->quantity + quantity: $productPriceDetails->quantity ); return [ - 'ticket_id' => $ticket->getId(), - 'ticket_price_id' => $ticketPriceDetails->price_id, - 'quantity' => $ticketPriceDetails->quantity, + 'product_type' => $product->getProductType(), + 'product_id' => $product->getId(), + 'product_price_id' => $productPriceDetails->price_id, + 'quantity' => $productPriceDetails->quantity, 'price_before_discount' => $priceBeforeDiscount, 'total_before_additions' => Currency::round($itemTotalWithDiscount), 'price' => $priceWithDiscount, 'order_id' => $order->getId(), - 'item_name' => $this->getOrderItemLabel($ticket, $ticketPriceDetails->price_id), + 'item_name' => $this->getOrderItemLabel($product, $productPriceDetails->price_id), 'total_tax' => $taxesAndFees->taxTotal, 'total_service_fee' => $taxesAndFees->feeTotal, 'total_gross' => Currency::round($itemTotalWithDiscount + $taxesAndFees->taxTotal + $taxesAndFees->feeTotal), @@ -108,14 +109,14 @@ private function calculateOrderItemData( ]; } - private function getOrderItemLabel(TicketDomainObject $ticket, int $priceId): string + private function getOrderItemLabel(ProductDomainObject $product, int $priceId): string { - if ($ticket->isTieredType()) { - return $ticket->getTitle() . ' - ' . $ticket->getTicketPrices() + if ($product->isTieredType()) { + return $product->getTitle() . ' - ' . $product->getProductPrices() ?->filter(fn($p) => $p->getId() === $priceId)->first() ?->getLabel(); } - return $ticket->getTitle(); + return $product->getTitle(); } } diff --git a/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentResponseDTO.php b/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentResponseDTO.php index f89b382b3b..4a68b74257 100644 --- a/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentResponseDTO.php +++ b/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentResponseDTO.php @@ -9,6 +9,7 @@ public function __construct( public ?string $clientSecret = null, public ?string $accountId = null, public ?string $error = null, + public int $applicationFeeAmount = 0, ) { } diff --git a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/ChargeRefundUpdatedHandler.php b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/ChargeRefundUpdatedHandler.php index fc73c41f80..5ba5122e69 100644 --- a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/ChargeRefundUpdatedHandler.php +++ b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/ChargeRefundUpdatedHandler.php @@ -3,27 +3,32 @@ namespace HiEvents\Services\Domain\Payment\Stripe\EventHandlers; use Brick\Money\Money; +use HiEvents\DomainObjects\Enums\PaymentProviders; +use HiEvents\DomainObjects\Enums\WebhookEventType; use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\Status\OrderRefundStatus; +use HiEvents\Repository\Interfaces\OrderRefundRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\StripePaymentsRepositoryInterface; use HiEvents\Services\Domain\EventStatistics\EventStatisticsUpdateService; +use HiEvents\Services\Infrastructure\Webhook\WebhookDispatchService; use HiEvents\Values\MoneyValue; use Illuminate\Database\DatabaseManager; use Illuminate\Log\Logger; use Stripe\Refund; use Throwable; -readonly class ChargeRefundUpdatedHandler +class ChargeRefundUpdatedHandler { public function __construct( - private OrderRepositoryInterface $orderRepository, - private StripePaymentsRepositoryInterface $stripePaymentsRepository, - private Logger $logger, - private DatabaseManager $databaseManager, - private EventStatisticsUpdateService $eventStatisticsUpdateService, - + private readonly OrderRepositoryInterface $orderRepository, + private readonly StripePaymentsRepositoryInterface $stripePaymentsRepository, + private readonly Logger $logger, + private readonly DatabaseManager $databaseManager, + private readonly EventStatisticsUpdateService $eventStatisticsUpdateService, + private readonly OrderRefundRepositoryInterface $orderRefundRepository, + private readonly WebhookDispatchService $webhookDispatchService, ) { } @@ -42,6 +47,20 @@ public function handleEvent(Refund $refund): void return; } + $existingRefund = $this->orderRefundRepository->findFirstWhere([ + 'refund_id' => $refund->id, + ]); + + if ($existingRefund) { + $this->logger->info(__('Refund already processed'), [ + 'refund_id' => $refund->id, + 'payment_intent_id' => $refund->payment_intent, + 'existing_refund' => $existingRefund->toArray(), + ]); + + return; + } + $order = $this->orderRepository->findById($stripePayment->getOrderId()); if ($refund->status !== 'succeeded') { @@ -54,6 +73,19 @@ public function handleEvent(Refund $refund): void $this->updateOrderRefundedAmount($order->getId(), $refundedAmount); $this->updateOrderStatus($order, $refundedAmount); $this->updateEventStatistics($order, MoneyValue::fromMinorUnit($refund->amount, $order->getCurrency())); + $this->createOrderRefund($refund, $order, $refundedAmount); + + $this->logger->info(__('Stripe refund successful'), [ + 'order_id' => $order->getId(), + 'refunded_amount' => $refundedAmount, + 'currency' => $order->getCurrency(), + 'refund_id' => $refund->id, + ]); + + $this->webhookDispatchService->queueOrderWebhook( + eventType: WebhookEventType::ORDER_REFUNDED, + orderId: $order->getId(), + ); }); } @@ -95,4 +127,19 @@ private function handleFailure(Refund $refund, OrderDomainObject $order): void $this->logger->error(__('Failed to refund stripe charge'), $refund->toArray()); } + + private function createOrderRefund(Refund $refund, OrderDomainObject $order, float $refundedAmount): void + { + $this->orderRefundRepository->create([ + 'order_id' => $order->getId(), + 'payment_provider' => PaymentProviders::STRIPE->value, + 'refund_id' => $refund->id, + 'amount' => $refundedAmount, + 'currency' => $order->getCurrency(), + 'status' => $refund->status, + 'metadata' => array_merge($refund->metadata?->toArray() ?? [], [ + 'payment_intent' => $refund->payment_intent, + ]), + ]); + } } diff --git a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentSucceededHandler.php b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentSucceededHandler.php index 8f7f37b29c..acc2593918 100644 --- a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentSucceededHandler.php +++ b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentSucceededHandler.php @@ -7,32 +7,46 @@ use Brick\Math\Exception\RoundingNecessaryException; use Brick\Money\Exception\UnknownCurrencyException; use Carbon\Carbon; +use HiEvents\DomainObjects\Enums\PaymentProviders; +use HiEvents\DomainObjects\Enums\WebhookEventType; use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract; use HiEvents\DomainObjects\Generated\StripePaymentDomainObjectAbstract; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; +use HiEvents\DomainObjects\Status\AttendeeStatus; +use HiEvents\DomainObjects\Status\OrderApplicationFeeStatus; use HiEvents\DomainObjects\Status\OrderPaymentStatus; use HiEvents\DomainObjects\Status\OrderStatus; use HiEvents\Events\OrderStatusChangedEvent; use HiEvents\Exceptions\CannotAcceptPaymentException; use HiEvents\Repository\Eloquent\StripePaymentsRepository; use HiEvents\Repository\Eloquent\Value\Relationship; +use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; +use HiEvents\Services\Domain\Order\OrderApplicationFeeService; use HiEvents\Services\Domain\Payment\Stripe\StripeRefundExpiredOrderService; -use HiEvents\Services\Domain\Ticket\TicketQuantityUpdateService; +use HiEvents\Services\Domain\Product\ProductQuantityUpdateService; +use HiEvents\Services\Infrastructure\Webhook\WebhookDispatchService; +use Illuminate\Cache\Repository; use Illuminate\Database\DatabaseManager; +use Psr\Log\LoggerInterface; use Stripe\Exception\ApiErrorException; use Stripe\PaymentIntent; use Throwable; -readonly class PaymentIntentSucceededHandler +class PaymentIntentSucceededHandler { public function __construct( - private OrderRepositoryInterface $orderRepository, - private StripePaymentsRepository $stripePaymentsRepository, - private TicketQuantityUpdateService $quantityUpdateService, - private StripeRefundExpiredOrderService $refundExpiredOrderService, - private DatabaseManager $databaseManager, + private readonly OrderRepositoryInterface $orderRepository, + private readonly StripePaymentsRepository $stripePaymentsRepository, + private readonly ProductQuantityUpdateService $quantityUpdateService, + private readonly StripeRefundExpiredOrderService $refundExpiredOrderService, + private readonly AttendeeRepositoryInterface $attendeeRepository, + private readonly DatabaseManager $databaseManager, + private readonly LoggerInterface $logger, + private readonly Repository $cache, + private readonly WebhookDispatchService $webhookDispatchService, + private readonly OrderApplicationFeeService $orderApplicationFeeService, ) { } @@ -42,6 +56,14 @@ public function __construct( */ public function handleEvent(PaymentIntent $paymentIntent): void { + if ($this->isPaymentIntentAlreadyHandled($paymentIntent)) { + $this->logger->info('Payment intent already handled', [ + 'payment_intent' => $paymentIntent->id, + ]); + + return; + } + $this->databaseManager->transaction(function () use ($paymentIntent) { /** @var StripePaymentDomainObjectAbstract $stripePayment */ $stripePayment = $this->stripePaymentsRepository @@ -50,15 +72,34 @@ public function handleEvent(PaymentIntent $paymentIntent): void StripePaymentDomainObjectAbstract::PAYMENT_INTENT_ID => $paymentIntent->id, ]); + if (!$stripePayment) { + $this->logger->error('Payment intent not found when handling payment intent succeeded event', [ + 'paymentIntent' => $paymentIntent->toArray(), + ]); + + return; + } + $this->validatePaymentAndOrderStatus($stripePayment, $paymentIntent); $this->updateStripePaymentInfo($paymentIntent, $stripePayment); $updatedOrder = $this->updateOrderStatuses($stripePayment); + $this->updateAttendeeStatuses($updatedOrder); + $this->quantityUpdateService->updateQuantitiesFromOrder($updatedOrder); OrderStatusChangedEvent::dispatch($updatedOrder); + + $this->webhookDispatchService->queueOrderWebhook( + eventType: WebhookEventType::ORDER_CREATED, + orderId: $updatedOrder->getId(), + ); + + $this->markPaymentIntentAsHandled($paymentIntent, $updatedOrder); + + $this->storeApplicationFeePayment($updatedOrder, $paymentIntent); }); } @@ -69,6 +110,7 @@ private function updateOrderStatuses(StripePaymentDomainObjectAbstract $stripePa ->updateFromArray($stripePayment->getOrderId(), [ OrderDomainObjectAbstract::PAYMENT_STATUS => OrderPaymentStatus::PAYMENT_RECEIVED->name, OrderDomainObjectAbstract::STATUS => OrderStatus::COMPLETED->name, + OrderDomainObjectAbstract::PAYMENT_PROVIDER => PaymentProviders::STRIPE->value, ]); } @@ -78,6 +120,7 @@ private function updateStripePaymentInfo(PaymentIntent $paymentIntent, StripePay attributes: [ StripePaymentDomainObjectAbstract::LAST_ERROR => $paymentIntent->last_payment_error?->toArray(), StripePaymentDomainObjectAbstract::AMOUNT_RECEIVED => $paymentIntent->amount_received, + StripePaymentDomainObjectAbstract::APPLICATION_FEE => $paymentIntent->application_fee_amount, StripePaymentDomainObjectAbstract::PAYMENT_METHOD_ID => is_string($paymentIntent->payment_method) ? $paymentIntent->payment_method : $paymentIntent->payment_method?->id, @@ -93,7 +136,7 @@ private function updateStripePaymentInfo(PaymentIntent $paymentIntent, StripePay /** * If the order has expired (reserved_until is in the past), refund the payment and throw an exception. - * This does seem quite extreme, but it ensures we don't oversell tickets. As far as I can see + * This does seem quite extreme, but it ensures we don't oversell products. As far as I can see * this is how Ticketmaster and other ticketing systems work. * * @throws ApiErrorException @@ -102,7 +145,7 @@ private function updateStripePaymentInfo(PaymentIntent $paymentIntent, StripePay * @throws MathException * @throws UnknownCurrencyException * @throws NumberFormatException - * @todo We could check to see if there are tickets available, and if so, complete the order. + * @todo We could check to see if there are products available, and if so, complete the order. * This would be a better user experience. * */ @@ -152,4 +195,44 @@ private function validatePaymentAndOrderStatus( $this->handleExpiredOrder($stripePayment, $paymentIntent); } + + private function updateAttendeeStatuses(OrderDomainObject $updatedOrder): void + { + $this->attendeeRepository->updateWhere( + attributes: [ + 'status' => AttendeeStatus::ACTIVE->name, + ], + where: [ + 'order_id' => $updatedOrder->getId(), + 'status' => AttendeeStatus::AWAITING_PAYMENT->name, + ], + ); + } + + private function markPaymentIntentAsHandled(PaymentIntent $paymentIntent, OrderDomainObject $updatedOrder): void + { + $this->logger->info('Stripe payment intent succeeded event handled', [ + 'payment_intent' => $paymentIntent->id, + 'order_id' => $updatedOrder->getId(), + 'amount_received' => $paymentIntent->amount_received, + 'currency' => $paymentIntent->currency, + ]); + + $this->cache->put('payment_intent_handled_' . $paymentIntent->id, true, 3600); + } + + private function isPaymentIntentAlreadyHandled(PaymentIntent $paymentIntent): bool + { + return $this->cache->has('payment_intent_handled_' . $paymentIntent->id); + } + + private function storeApplicationFeePayment(OrderDomainObject $updatedOrder, PaymentIntent $paymentIntent): void + { + $this->orderApplicationFeeService->createOrderApplicationFee( + orderId: $updatedOrder->getId(), + applicationFeeAmount: $paymentIntent->application_fee_amount / 100, + orderApplicationFeeStatus: OrderApplicationFeeStatus::PAID, + paymentMethod: PaymentProviders::STRIPE, + ); + } } diff --git a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php index be408ef995..0906335872 100644 --- a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php +++ b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php @@ -5,6 +5,7 @@ use HiEvents\DomainObjects\StripeCustomerDomainObject; use HiEvents\Exceptions\Stripe\CreatePaymentIntentFailedException; use HiEvents\Repository\Interfaces\StripeCustomerRepositoryInterface; +use HiEvents\Services\Domain\Order\OrderApplicationFeeCalculationService; use HiEvents\Services\Domain\Payment\Stripe\DTOs\CreatePaymentIntentRequestDTO; use HiEvents\Services\Domain\Payment\Stripe\DTOs\CreatePaymentIntentResponseDTO; use Illuminate\Config\Repository; @@ -17,11 +18,12 @@ class StripePaymentIntentCreationService { public function __construct( - readonly private StripeClient $stripeClient, - readonly private LoggerInterface $logger, - readonly private Repository $config, - readonly private StripeCustomerRepositoryInterface $stripeCustomerRepository, - readonly private DatabaseManager $databaseManager, + private readonly StripeClient $stripeClient, + private readonly LoggerInterface $logger, + private readonly Repository $config, + private readonly StripeCustomerRepositoryInterface $stripeCustomerRepository, + private readonly DatabaseManager $databaseManager, + private readonly OrderApplicationFeeCalculationService $orderApplicationFeeCalculationService, ) { } @@ -60,13 +62,15 @@ public function createPaymentIntent(CreatePaymentIntentRequestDTO $paymentIntent try { $this->databaseManager->beginTransaction(); - $applicationFee = $this->getApplicationFee($paymentIntentDTO); + $applicationFee = $this->orderApplicationFeeCalculationService->calculateApplicationFee( + accountConfiguration: $paymentIntentDTO->account->getConfiguration(), + orderTotal: $paymentIntentDTO->amount / 100 + ); $paymentIntent = $this->stripeClient->paymentIntents->create([ 'amount' => $paymentIntentDTO->amount, 'currency' => $paymentIntentDTO->currencyCode, 'customer' => $this->upsertStripeCustomer($paymentIntentDTO)->getStripeCustomerId(), - 'setup_future_usage' => 'on_session', 'metadata' => [ 'order_id' => $paymentIntentDTO->order->getId(), 'event_id' => $paymentIntentDTO->order->getEventId(), @@ -90,6 +94,7 @@ public function createPaymentIntent(CreatePaymentIntentRequestDTO $paymentIntent paymentIntentId: $paymentIntent->id, clientSecret: $paymentIntent->client_secret, accountId: $paymentIntentDTO->account->getStripeAccountId(), + applicationFeeAmount: $applicationFee, ); } catch (ApiErrorException $exception) { $this->logger->error("Stripe payment intent creation failed: {$exception->getMessage()}", [ @@ -113,7 +118,10 @@ private function getApplicationFee(CreatePaymentIntentRequestDTO $paymentIntentD return 0; } - return ceil($paymentIntentDTO->amount * $this->config->get('app.saas_stripe_application_fee_percent') / 100); + $fixedFee = $paymentIntentDTO->account->getApplicationFee()->fixedFee; + $percentageFee = $paymentIntentDTO->account->getApplicationFee()->percentageFee; + + return ceil(($fixedFee * 100) + ($paymentIntentDTO->amount * $percentageFee / 100)); } /** @@ -149,6 +157,7 @@ private function upsertStripeCustomer(CreatePaymentIntentRequestDTO $paymentInte { $customer = $this->stripeCustomerRepository->findFirstWhere([ 'email' => $paymentIntentDTO->order->getEmail(), + 'stripe_account_id' => $paymentIntentDTO->account->getStripeAccountId(), ]); if ($customer === null) { @@ -164,6 +173,7 @@ private function upsertStripeCustomer(CreatePaymentIntentRequestDTO $paymentInte 'name' => $stripeCustomer->name, 'email' => $stripeCustomer->email, 'stripe_customer_id' => $stripeCustomer->id, + 'stripe_account_id' => $paymentIntentDTO->account->getStripeAccountId(), ]); } diff --git a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php index 108b8cb97d..708e68bc70 100644 --- a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php +++ b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php @@ -35,7 +35,8 @@ public function refundPayment( 'payment_intent' => $payment->getPaymentIntentId(), 'amount' => $amount->toMinorUnit() ], - opts: $this->getStripeAccountData($payment)); + opts: $this->getStripeAccountData($payment), + ); } private function getStripeAccountData(StripePaymentDomainObject $payment): array @@ -47,7 +48,9 @@ private function getStripeAccountData(StripePaymentDomainObject $payment): array ); } - return ['stripe_account' => $payment->getConnectedAccountId()]; + return [ + 'stripe_account' => $payment->getConnectedAccountId(), + ]; } return []; diff --git a/backend/app/Services/Domain/Product/AvailableProductQuantitiesFetchService.php b/backend/app/Services/Domain/Product/AvailableProductQuantitiesFetchService.php new file mode 100644 index 0000000000..12e060e2dc --- /dev/null +++ b/backend/app/Services/Domain/Product/AvailableProductQuantitiesFetchService.php @@ -0,0 +1,173 @@ +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, + ]); + + $reservedProductQuantities = $this->fetchReservedProductQuantities($eventId); + $productCapacities = $this->calculateProductCapacities($capacities); + + $quantities = $reservedProductQuantities->map(function (AvailableProductQuantitiesDTO $dto) use ($productCapacities) { + $productId = $dto->product_id; + if (isset($productCapacities[$productId])) { + $dto->quantity_available = min(array_merge([$dto->quantity_available], $productCapacities[$productId]->map->getAvailableCapacity()->toArray())); + $dto->capacities = $productCapacities[$productId]; + } + + return $dto; + }); + + $finalData = new AvailableProductQuantitiesResponseDTO( + productQuantities: $quantities, + capacities: $capacities + ); + + if (!$ignoreCache && $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 fetchReservedProductQuantities(int $eventId): Collection + { + $result = $this->db->select(<< NOW() + AND orders.deleted_at IS NULL + THEN order_items.quantity + ELSE 0 + END + ) AS quantity_reserved + FROM products + JOIN product_prices ON products.id = product_prices.product_id + LEFT JOIN order_items ON order_items.product_id = products.id + AND order_items.product_price_id = product_prices.id + LEFT JOIN orders ON orders.id = order_items.order_id + AND orders.event_id = products.event_id + AND orders.deleted_at IS NULL + WHERE + products.event_id = :eventId + AND products.deleted_at IS NULL + AND product_prices.deleted_at IS NULL + GROUP BY products.id, product_prices.id + ) + SELECT + products.id AS product_id, + product_prices.id AS product_price_id, + products.title AS product_title, + product_prices.label AS price_label, + product_prices.initial_quantity_available, + product_prices.quantity_sold, + COALESCE( + product_prices.initial_quantity_available + - product_prices.quantity_sold + - COALESCE(reserved_quantities.quantity_reserved, 0), + 0) AS quantity_available, + COALESCE(reserved_quantities.quantity_reserved, 0) AS quantity_reserved, + CASE WHEN product_prices.initial_quantity_available IS NULL + THEN TRUE + ELSE FALSE + END AS unlimited_quantity_available + FROM products + JOIN product_prices ON products.id = product_prices.product_id + LEFT JOIN reserved_quantities ON products.id = reserved_quantities.product_id + AND product_prices.id = reserved_quantities.product_price_id + WHERE + products.event_id = :eventId + AND products.deleted_at IS NULL + AND product_prices.deleted_at IS NULL + GROUP BY products.id, product_prices.id, reserved_quantities.quantity_reserved; + SQL, [ + 'eventId' => $eventId, + 'reserved' => OrderStatus::RESERVED->name + ]); + + return collect($result)->map(fn($row) => AvailableProductQuantitiesDTO::fromArray([ + 'product_id' => $row->product_id, + 'price_id' => $row->product_price_id, + 'product_title' => $row->product_title, + 'price_label' => $row->price_label, + 'quantity_available' => $row->unlimited_quantity_available ? Constants::INFINITE : $row->quantity_available, + 'initial_quantity_available' => $row->initial_quantity_available, + 'quantity_reserved' => $row->quantity_reserved, + 'capacities' => new Collection(), + ])); + } + + /** + * @param Collection $capacities + */ + private function calculateProductCapacities(Collection $capacities): array + { + $productCapacities = []; + foreach ($capacities as $capacity) { + foreach ($capacity->getProducts() as $product) { + $productId = $product->getId(); + if (!isset($productCapacities[$productId])) { + $productCapacities[$productId] = collect(); + } + + $productCapacities[$productId]->push($capacity); + } + } + + return $productCapacities; + } + + private function getDataFromCache(int $eventId): ?AvailableProductQuantitiesResponseDTO + { + return $this->cache->get($this->getCacheKey($eventId)); + } + + private function getCacheKey(int $eventId): string + { + return "event.$eventId.available_product_quantities"; + } +} diff --git a/backend/app/Services/Domain/Product/CreateProductService.php b/backend/app/Services/Domain/Product/CreateProductService.php new file mode 100644 index 0000000000..6a79f73ea5 --- /dev/null +++ b/backend/app/Services/Domain/Product/CreateProductService.php @@ -0,0 +1,134 @@ +databaseManager->transaction(function () use ($accountId, $taxAndFeeIds, $product) { + $persistedProduct = $this->persistProduct($product); + + if ($taxAndFeeIds) { + $this->associateTaxesAndFees($persistedProduct, $taxAndFeeIds, $accountId); + } + + $product = $this->createProductPrices($persistedProduct, $product); + + $this->webhookDispatchService->queueProductWebhook( + eventType: WebhookEventType::PRODUCT_CREATED, + productId: $product->getId(), + ); + + return $product; + }); + } + + private function persistProduct(ProductDomainObject $productsData): ProductDomainObject + { + $event = $this->eventRepository->findById($productsData->getEventId()); + + return $this->productRepository->create([ + 'title' => $productsData->getTitle(), + 'type' => $productsData->getType(), + 'product_type' => $productsData->getProductType(), + 'order' => $this->productOrderingService->getOrderForNewProduct( + eventId: $productsData->getEventId(), + productCategoryId: $productsData->getProductCategoryId(), + ), + 'sale_start_date' => $productsData->getSaleStartDate() + ? DateHelper::convertToUTC($productsData->getSaleStartDate(), $event->getTimezone()) + : null, + 'sale_end_date' => $productsData->getSaleEndDate() + ? DateHelper::convertToUTC($productsData->getSaleEndDate(), $event->getTimezone()) + : null, + 'max_per_order' => $productsData->getMaxPerOrder(), + 'description' => $this->purifier->purify($productsData->getDescription()), + 'start_collapsed' => $productsData->getStartCollapsed(), + 'min_per_order' => $productsData->getMinPerOrder(), + 'is_hidden' => $productsData->getIsHidden(), + 'hide_before_sale_start_date' => $productsData->getHideBeforeSaleStartDate(), + 'hide_after_sale_end_date' => $productsData->getHideAfterSaleEndDate(), + 'hide_when_sold_out' => $productsData->getHideWhenSoldOut(), + 'show_quantity_remaining' => $productsData->getShowQuantityRemaining(), + 'is_hidden_without_promo_code' => $productsData->getIsHiddenWithoutPromoCode(), + 'event_id' => $productsData->getEventId(), + 'product_category_id' => $productsData->getProductCategoryId(), + ]); + } + + /** + * @throws Exception + */ + private function createProductTaxesAndFees( + ProductDomainObject $product, + array $taxAndFeeIds, + int $accountId, + ): Collection + { + return $this->taxAndProductAssociationService->addTaxesToProduct( + new TaxAndProductAssociateParams( + productId: $product->getId(), + accountId: $accountId, + taxAndFeeIds: $taxAndFeeIds, + ), + ); + } + + /** + * @throws Exception + */ + private function associateTaxesAndFees(ProductDomainObject $persistedProduct, array $taxAndFeeIds, int $accountId): void + { + $persistedProduct->setTaxAndFees($this->createProductTaxesAndFees( + product: $persistedProduct, + taxAndFeeIds: $taxAndFeeIds, + accountId: $accountId, + )); + } + + private function createProductPrices(ProductDomainObject $persistedProduct, ProductDomainObject $product): ProductDomainObject + { + $prices = $this->priceCreateService->createPrices( + productId: $persistedProduct->getId(), + prices: $product->getProductPrices(), + event: $this->eventRepository->findById($product->getEventId()), + ); + + return $persistedProduct->setProductPrices($prices); + } +} diff --git a/backend/app/Services/Domain/Ticket/DTO/AvailableTicketQuantitiesDTO.php b/backend/app/Services/Domain/Product/DTO/AvailableProductQuantitiesDTO.php similarity index 74% rename from backend/app/Services/Domain/Ticket/DTO/AvailableTicketQuantitiesDTO.php rename to backend/app/Services/Domain/Product/DTO/AvailableProductQuantitiesDTO.php index 72c032b6b1..01c68affaf 100644 --- a/backend/app/Services/Domain/Ticket/DTO/AvailableTicketQuantitiesDTO.php +++ b/backend/app/Services/Domain/Product/DTO/AvailableProductQuantitiesDTO.php @@ -1,17 +1,17 @@ */ - public Collection $ticketQuantities, + /** @var Collection */ + public Collection $productQuantities, /** @var Collection */ public ?Collection $capacities = null, ) diff --git a/backend/app/Services/Domain/Product/DTO/CreateProductDTO.php b/backend/app/Services/Domain/Product/DTO/CreateProductDTO.php new file mode 100644 index 0000000000..aca5aab20e --- /dev/null +++ b/backend/app/Services/Domain/Product/DTO/CreateProductDTO.php @@ -0,0 +1,10 @@ +databaseManager->transaction(function () use ($productId, $eventId) { + if ($this->productRepository->hasAssociatedOrders($productId)) { + throw new CannotDeleteEntityException( + __('You cannot delete this product because it has orders associated with it. You can hide it instead.') + ); + } + + $this->productRepository->deleteWhere( + [ + ProductDomainObjectAbstract::EVENT_ID => $eventId, + ProductDomainObjectAbstract::ID => $productId, + ] + ); + + $this->productPriceRepository->deleteWhere( + [ + ProductPriceDomainObjectAbstract::PRODUCT_ID => $productId, + ] + ); + }); + + $this->webhookDispatchService->queueProductWebhook( + eventType: WebhookEventType::PRODUCT_DELETED, + productId: $productId, + ); + + $this->logger->info( + sprintf('Product with id %d was deleted from event with id %d', $productId, $eventId), + [ + 'product_id' => $productId, + 'event_id' => $eventId, + ] + ); + } +} diff --git a/backend/app/Services/Domain/Product/EventProductValidationService.php b/backend/app/Services/Domain/Product/EventProductValidationService.php new file mode 100644 index 0000000000..ae46396bda --- /dev/null +++ b/backend/app/Services/Domain/Product/EventProductValidationService.php @@ -0,0 +1,35 @@ +productRepository->findWhere([ + 'event_id' => $eventId, + ])->map(fn(ProductDomainObject $product) => $product->getId()) + ->toArray(); + + $invalidProductIds = array_diff($productIds, $validProductIds); + + if (!empty($invalidProductIds)) { + throw new UnrecognizedProductIdException( + __('Invalid product ids: :ids', ['ids' => implode(', ', $invalidProductIds)]) + ); + } + } +} diff --git a/backend/app/Services/Domain/Product/Exception/UnrecognizedProductIdException.php b/backend/app/Services/Domain/Product/Exception/UnrecognizedProductIdException.php new file mode 100644 index 0000000000..f788e3fe82 --- /dev/null +++ b/backend/app/Services/Domain/Product/Exception/UnrecognizedProductIdException.php @@ -0,0 +1,10 @@ + $productsCategories + * @param PromoCodeDomainObject|null $promoCode + * @param bool $hideSoldOutProducts + * @return Collection + */ + public function filter( + Collection $productsCategories, + ?PromoCodeDomainObject $promoCode = null, + bool $hideSoldOutProducts = true, + ): Collection + { + if ($productsCategories->isEmpty()) { + return $productsCategories; + } + + $products = $productsCategories + ->flatMap(fn(ProductCategoryDomainObject $category) => $category->getProducts()); + + if ($products->isEmpty()) { + return $productsCategories + ->reject(fn(ProductCategoryDomainObject $category) => $category->getIsHidden()); + } + + $productQuantities = $this + ->fetchAvailableProductQuantitiesService + ->getAvailableProductQuantities($products->first()->getEventId()); + + $filteredProducts = $products + ->map(fn(ProductDomainObject $product) => $this->processProduct($product, $productQuantities->productQuantities, $promoCode)) + ->reject(fn(ProductDomainObject $product) => $this->filterProduct($product, $promoCode, $hideSoldOutProducts)) + ->each(fn(ProductDomainObject $product) => $this->processProductPrices($product, $hideSoldOutProducts)); + + return $productsCategories + ->reject(fn(ProductCategoryDomainObject $category) => $category->getIsHidden()) + ->each(fn(ProductCategoryDomainObject $category) => $category->setProducts( + $filteredProducts->where( + static fn(ProductDomainObject $product) => $product->getProductCategoryId() === $category->getId() + ) + )); + } + + private function isHiddenByPromoCode(ProductDomainObject $product, ?PromoCodeDomainObject $promoCode): bool + { + return $product->getIsHiddenWithoutPromoCode() && !( + $promoCode + && $promoCode->appliesToProduct($product) + ); + } + + private function shouldProductBeDiscounted(?PromoCodeDomainObject $promoCode, ProductDomainObject $product): bool + { + if ($product->isDonationType() || $product->isFreeType()) { + return false; + } + + return $promoCode + && $promoCode->isDiscountCode() + && $promoCode->appliesToProduct($product); + } + + /** + * @param PromoCodeDomainObject|null $promoCode + * @param ProductDomainObject $product + * @param Collection $productQuantities + * @return ProductDomainObject + */ + private function processProduct( + ProductDomainObject $product, + Collection $productQuantities, + ?PromoCodeDomainObject $promoCode = null, + ): ProductDomainObject + { + if ($this->shouldProductBeDiscounted($promoCode, $product)) { + $product->getProductPrices()?->each(function (ProductPriceDomainObject $price) use ($product, $promoCode) { + $price->setPriceBeforeDiscount($price->getPrice()); + $price->setPrice($this->productPriceService->getIndividualPrice($product, $price, $promoCode)); + }); + } + + $product->getProductPrices()?->map(function (ProductPriceDomainObject $price) use ($productQuantities) { + $availableQuantity = $productQuantities->where('price_id', $price->getId())->first()?->quantity_available; + $availableQuantity = $availableQuantity === Constants::INFINITE ? null : $availableQuantity; + $price->setQuantityAvailable( + max($availableQuantity, 0) + ); + }); + + // If there is a capacity assigned to the product, we set the capacity to capacity available qty, or the sum of all + // product prices qty, whichever is lower + $productQuantities->each(function (AvailableProductQuantitiesDTO $quantity) use ($product) { + if ($quantity->capacities !== null && $quantity->capacities->isNotEmpty() && $quantity->product_id === $product->getId()) { + $product->setQuantityAvailable( + $quantity->capacities->min(fn(CapacityAssignmentDomainObject $capacity) => $capacity->getAvailableCapacity()) + ); + } + }); + + return $product; + } + + private function filterProduct( + ProductDomainObject $product, + ?PromoCodeDomainObject $promoCode = null, + bool $hideSoldOutProducts = true, + ): bool + { + $hidden = false; + + if ($this->isHiddenByPromoCode($product, $promoCode)) { + $product->setOffSaleReason(__('Product is hidden without promo code')); + $hidden = true; + } + + if ($product->isSoldOut() && $product->getHideWhenSoldOut()) { + $product->setOffSaleReason(__('Product is sold out')); + $hidden = true; + } + + if ($product->isBeforeSaleStartDate() && $product->getHideBeforeSaleStartDate()) { + $product->setOffSaleReason(__('Product is before sale start date')); + $hidden = true; + } + + if ($product->isAfterSaleEndDate() && $product->getHideAfterSaleEndDate()) { + $product->setOffSaleReason(__('Product is after sale end date')); + $hidden = true; + } + + if ($product->getIsHidden()) { + $product->setOffSaleReason(__('Product is hidden')); + $hidden = true; + } + + return $hidden && $hideSoldOutProducts; + } + + private function processProductPrice(ProductDomainObject $product, ProductPriceDomainObject $price): void + { + $taxAndFees = $this->taxCalculationService + ->calculateTaxAndFeesForProductPrice($product, $price); + + $price + ->setTaxTotal(Currency::round($taxAndFees->taxTotal)) + ->setFeeTotal(Currency::round($taxAndFees->feeTotal)); + + $price->setIsAvailable($this->getPriceAvailability($price, $product)); + } + + private function filterProductPrice( + ProductDomainObject $product, + ProductPriceDomainObject $price, + bool $hideSoldOutProducts = true + ): bool + { + $hidden = false; + + if (!$product->isTieredType()) { + return false; + } + + if ($price->isBeforeSaleStartDate() && $product->getHideBeforeSaleStartDate()) { + $price->setOffSaleReason(__('Price is before sale start date')); + $hidden = true; + } + + if ($price->isAfterSaleEndDate() && $product->getHideAfterSaleEndDate()) { + $price->setOffSaleReason(__('Price is after sale end date')); + $hidden = true; + } + + if ($price->isSoldOut() && $product->getHideWhenSoldOut()) { + $price->setOffSaleReason(__('Price is sold out')); + $hidden = true; + } + + if ($price->getIsHidden()) { + $price->setOffSaleReason(__('Price is hidden')); + $hidden = true; + } + + return $hidden && $hideSoldOutProducts; + } + + private function processProductPrices(ProductDomainObject $product, bool $hideSoldOutProducts = true): void + { + $product->setProductPrices( + $product->getProductPrices() + ?->each(fn(ProductPriceDomainObject $price) => $this->processProductPrice($product, $price)) + ->reject(fn(ProductPriceDomainObject $price) => $this->filterProductPrice($product, $price, $hideSoldOutProducts)) + ); + } + + /** + * For non-tiered products, we can inherit the availability of the product. + * + * @param ProductPriceDomainObject $price + * @param ProductDomainObject $product + * @return bool + */ + private function getPriceAvailability(ProductPriceDomainObject $price, ProductDomainObject $product): bool + { + if ($product->isTieredType()) { + return !$price->isSoldOut() + && !$price->isBeforeSaleStartDate() + && !$price->isAfterSaleEndDate() + && !$price->getIsHidden(); + } + + return !$product->isSoldOut() + && !$product->isBeforeSaleStartDate() + && !$product->isAfterSaleEndDate() + && !$product->getIsHidden(); + } +} diff --git a/backend/app/Services/Domain/Product/ProductOrderingService.php b/backend/app/Services/Domain/Product/ProductOrderingService.php new file mode 100644 index 0000000000..11e9498ae5 --- /dev/null +++ b/backend/app/Services/Domain/Product/ProductOrderingService.php @@ -0,0 +1,24 @@ +productRepository->findWhere([ + 'event_id' => $eventId, + 'product_category_id' => $productCategoryId, + ]) + ->max((static fn(ProductDomainObject $product) => $product->getOrder())) ?? 0) + 1; + } +} diff --git a/backend/app/Services/Domain/Ticket/TicketPriceCreateService.php b/backend/app/Services/Domain/Product/ProductPriceCreateService.php similarity index 66% rename from backend/app/Services/Domain/Ticket/TicketPriceCreateService.php rename to backend/app/Services/Domain/Product/ProductPriceCreateService.php index bff6de6dc7..57fae4a659 100644 --- a/backend/app/Services/Domain/Ticket/TicketPriceCreateService.php +++ b/backend/app/Services/Domain/Product/ProductPriceCreateService.php @@ -1,29 +1,29 @@ map(fn(TicketPriceDomainObject $price, int $index) => $this->ticketPriceRepository->create([ - 'ticket_id' => $ticketId, + return (new Collection($prices->map(fn(ProductPriceDomainObject $price, int $index) => $this->productPriceRepository->create([ + 'product_id' => $productId, 'price' => $price->getPrice(), 'label' => $price->getLabel(), 'sale_start_date' => $price->getSaleStartDate() diff --git a/backend/app/Services/Domain/Product/ProductPriceService.php b/backend/app/Services/Domain/Product/ProductPriceService.php new file mode 100644 index 0000000000..aa29d65503 --- /dev/null +++ b/backend/app/Services/Domain/Product/ProductPriceService.php @@ -0,0 +1,77 @@ +getPrice($product, new OrderProductPriceDTO( + quantity: 1, + price_id: $price->getId(), + ), $promoCode)->price; + } + + public function getPrice( + ProductDomainObject $product, + OrderProductPriceDTO $productOrderDetail, + ?PromoCodeDomainObject $promoCode + ): PriceDTO + { + $price = $this->determineProductPrice($product, $productOrderDetail); + + if ($product->getType() === ProductPriceType::FREE->name) { + return new PriceDTO(0.00); + } + + if ($product->getType() === ProductPriceType::DONATION->name) { + return new PriceDTO($price); + } + + if (!$promoCode || !$promoCode->appliesToProduct($product)) { + return new PriceDTO($price); + } + + if ($promoCode->getDiscountType() === PromoCodeDiscountTypeEnum::NONE->name) { + return new PriceDTO($price); + } + + if ($promoCode->isFixedDiscount()) { + $discountPrice = Currency::round($price - $promoCode->getDiscount()); + } elseif ($promoCode->isPercentageDiscount()) { + $discountPrice = Currency::round( + $price - ($price * ($promoCode->getDiscount() / 100)) + ); + } else { + $discountPrice = $price; + } + + return new PriceDTO( + price: max(0, $discountPrice), + price_before_discount: $price + ); + } + + private function determineProductPrice(ProductDomainObject $product, OrderProductPriceDTO $productOrderDetails): float + { + return match ($product->getType()) { + ProductPriceType::DONATION->name => max($product->getPrice(), $productOrderDetails->price), + ProductPriceType::PAID->name => $product->getPrice(), + ProductPriceType::FREE->name => 0.00, + ProductPriceType::TIERED->name => $product->getPriceById($productOrderDetails->price_id)?->getPrice() + }; + } +} diff --git a/backend/app/Services/Domain/Ticket/TicketPriceUpdateService.php b/backend/app/Services/Domain/Product/ProductPriceUpdateService.php similarity index 60% rename from backend/app/Services/Domain/Ticket/TicketPriceUpdateService.php rename to backend/app/Services/Domain/Product/ProductPriceUpdateService.php index f2bef504e6..05f1e34c06 100644 --- a/backend/app/Services/Domain/Ticket/TicketPriceUpdateService.php +++ b/backend/app/Services/Domain/Product/ProductPriceUpdateService.php @@ -1,22 +1,22 @@ $existingPrices */ - Collection $existingPrices, - EventDomainObject $event, + ProductDomainObject $product, + UpsertProductDTO $productsData, + /** @var Collection $existingPrices */ + Collection $existingPrices, + EventDomainObject $event, ): void { - if ($ticketsData->type !== TicketType::TIERED) { - $prices = new Collection([new TicketPriceDTO( - price: $ticketsData->type === TicketType::FREE ? 0.00 : $ticketsData->prices->first()->price, + if ($productsData->type !== ProductPriceType::TIERED) { + $prices = new Collection([new ProductPriceDTO( + price: $productsData->type === ProductPriceType::FREE ? 0.00 : $productsData->prices->first()->price, label: null, sale_start_date: null, sale_end_date: null, - initial_quantity_available: $ticketsData->prices->first()->initial_quantity_available, + initial_quantity_available: $productsData->prices->first()->initial_quantity_available, id: $existingPrices->first()->getId(), )]); } else { - $prices = $ticketsData->prices; + $prices = $productsData->prices; } $order = 1; foreach ($prices as $price) { if ($price->id === null) { - $this->ticketPriceRepository->create([ - 'ticket_id' => $ticket->getId(), + $this->productPriceRepository->create([ + 'product_id' => $product->getId(), 'price' => $price->price, 'label' => $price->label, 'sale_start_date' => $price->sale_start_date @@ -64,8 +64,8 @@ public function updatePrices( 'order' => $order++, ]); } else { - $this->ticketPriceRepository->updateWhere([ - 'ticket_id' => $ticket->getId(), + $this->productPriceRepository->updateWhere([ + 'product_id' => $product->getId(), 'price' => $price->price, 'label' => $price->label, 'sale_start_date' => $price->sale_start_date @@ -93,16 +93,16 @@ private function deletePrices(?Collection $prices, Collection $existingPrices): { $pricesIds = $prices?->map(fn($price) => $price->id)->toArray(); - $existingPrices->each(function (TicketPriceDomainObject $price) use ($pricesIds) { - if (in_array($price->getId(), $pricesIds)) { + $existingPrices->each(function (ProductPriceDomainObject $price) use ($pricesIds) { + if (in_array($price->getId(), $pricesIds, true)) { return; } if ($price->getQuantitySold() > 0) { throw new CannotDeleteEntityException( - __('Cannot delete ticket price with id :id because it has sales', ['id' => $price->getId()]) + __('Cannot delete product price with id :id because it has sales', ['id' => $price->getId()]) ); } - $this->ticketPriceRepository->deleteById($price->getId()); + $this->productPriceRepository->deleteById($price->getId()); }); } } diff --git a/backend/app/Services/Domain/Ticket/TicketQuantityUpdateService.php b/backend/app/Services/Domain/Product/ProductQuantityUpdateService.php similarity index 79% rename from backend/app/Services/Domain/Ticket/TicketQuantityUpdateService.php rename to backend/app/Services/Domain/Product/ProductQuantityUpdateService.php index edfe240298..58b1b0cba4 100644 --- a/backend/app/Services/Domain/Ticket/TicketQuantityUpdateService.php +++ b/backend/app/Services/Domain/Product/ProductQuantityUpdateService.php @@ -1,24 +1,24 @@ increaseCapacityAssignmentUsedCapacity($capacityAssignment->getId(), $adjustment); }); - $this->ticketPriceRepository->updateWhere([ + $this->productPriceRepository->updateWhere([ 'quantity_sold' => DB::raw('quantity_sold + ' . $adjustment), ], [ 'id' => $priceId, @@ -51,7 +51,7 @@ public function decreaseQuantitySold(int $priceId, int $adjustment = 1): void $this->decreaseCapacityAssignmentUsedCapacity($capacityAssignment->getId(), $adjustment); }); - $this->ticketPriceRepository->updateWhere([ + $this->productPriceRepository->updateWhere([ 'quantity_sold' => DB::raw('quantity_sold - ' . $adjustment), ], [ 'id' => $priceId, @@ -65,11 +65,11 @@ public function decreaseQuantitySold(int $priceId, int $adjustment = 1): void public function updateQuantitiesFromOrder(OrderDomainObject $order): void { $this->databaseManager->transaction(function () use ($order) { - if (!$order->getOrderItems() === null) { + if ($order->getOrderItems() === null) { throw new InvalidArgumentException(__('Order has no order items')); } - $this->updateTicketQuantities($order); + $this->updateProductQuantities($order); }); } @@ -77,11 +77,11 @@ public function updateQuantitiesFromOrder(OrderDomainObject $order): void * @param OrderDomainObject $order * @return void */ - private function updateTicketQuantities(OrderDomainObject $order): void + private function updateProductQuantities(OrderDomainObject $order): void { /** @var OrderItemDomainObject $orderItem */ foreach ($order->getOrderItems() as $orderItem) { - $this->increaseQuantitySold($orderItem->getTicketPriceId(), $orderItem->getQuantity()); + $this->increaseQuantitySold($orderItem->getProductPriceId(), $orderItem->getQuantity()); } } @@ -109,10 +109,10 @@ private function decreaseCapacityAssignmentUsedCapacity(int $capacityAssignmentI */ private function getCapacityAssignments(int $priceId): Collection { - $price = $this->ticketPriceRepository->findFirstWhere([ + $price = $this->productPriceRepository->findFirstWhere([ 'id' => $priceId, ]); - return $this->ticketRepository->getCapacityAssignmentsByTicketId($price->getTicketId()); + return $this->productRepository->getCapacityAssignmentsByProductId($price->getProductId()); } } diff --git a/backend/app/Services/Domain/ProductCategory/CreateProductCategoryService.php b/backend/app/Services/Domain/ProductCategory/CreateProductCategoryService.php new file mode 100644 index 0000000000..9ca7b8bc41 --- /dev/null +++ b/backend/app/Services/Domain/ProductCategory/CreateProductCategoryService.php @@ -0,0 +1,31 @@ +productCategoryRepository->create(array_filter($productCategoryDomainObject->toArray())); + } + + public function createDefaultProductCategory(EventDomainObject $event): void + { + $this->createCategory((new ProductCategoryDomainObject()) + ->setEventId($event->getId()) + ->setName(__('Tickets')) + ->setIsHidden(false) + ->setNoProductsMessage(__('There are no tickets available for this event')) + ); + } +} diff --git a/backend/app/Services/Domain/ProductCategory/DeleteProductCategoryService.php b/backend/app/Services/Domain/ProductCategory/DeleteProductCategoryService.php new file mode 100644 index 0000000000..f48765cfb6 --- /dev/null +++ b/backend/app/Services/Domain/ProductCategory/DeleteProductCategoryService.php @@ -0,0 +1,117 @@ +databaseManager->transaction(function () use ($productCategoryId, $eventId) { + $this->handleDeletion($productCategoryId, $eventId); + }); + } + + /** + * @throws Throwable + * @throws CannotDeleteEntityException + */ + private function handleDeletion(int $productCategoryId, int $eventId): void + { + $this->validateCanDeleteProductCategory($eventId); + + $this->deleteCategoryProducts($productCategoryId, $eventId); + + $this->deleteCategory($productCategoryId, $eventId); + } + + /** + * @throws CannotDeleteEntityException + * @throws Throwable + */ + private function deleteCategoryProducts(int $productCategoryId, int $eventId): void + { + $productsToDelete = $this->productRepository->findWhere( + [ + ProductDomainObjectAbstract::PRODUCT_CATEGORY_ID => $productCategoryId, + ProductDomainObjectAbstract::EVENT_ID => $eventId, + ] + ); + + $productsWhichCanNotBeDeleted = new Collection(); + + foreach ($productsToDelete as $product) { + try { + $this->deleteProductService->deleteProduct($product->getId(), $eventId); + } catch (CannotDeleteEntityException) { + $productsWhichCanNotBeDeleted->push($product); + } + } + + if ($productsWhichCanNotBeDeleted->isNotEmpty()) { + throw new CannotDeleteEntityException( + __('You cannot delete this product category because it contains the following products: :products. These products are linked to existing orders. Please move the :product_name to another category before attempting to delete this one.', [ + 'products' => $productsWhichCanNotBeDeleted->map(fn($product) => $product->getTitle())->implode(', '), + 'product_name' => $productsWhichCanNotBeDeleted->count() > 1 ? __('products') : __('product'), + ]) + ); + } + } + + private function deleteCategory(int $productCategoryId, int $eventId): void + { + $this->productCategoryRepository->deleteWhere( + [ + ProductCategoryDomainObjectAbstract::ID => $productCategoryId, + ProductCategoryDomainObjectAbstract::EVENT_ID => $eventId, + ] + ); + + $this->logger->info(__('Product category :productCategoryId has been deleted.', [ + 'product_category_id' => $productCategoryId, + 'event_id' => $eventId, + ])); + } + + /** + * @throws CannotDeleteEntityException + */ + private function validateCanDeleteProductCategory(int $eventId): void + { + $existingRelatedCategories = $this->productCategoryRepository->findWhere( + [ + ProductCategoryDomainObjectAbstract::EVENT_ID => $eventId, + ] + ); + + if ($existingRelatedCategories->count() === 1) { + throw new CannotDeleteEntityException( + __('You cannot delete the last product category. Please create another category before deleting this one.') + ); + } + } +} diff --git a/backend/app/Services/Domain/ProductCategory/GetProductCategoryService.php b/backend/app/Services/Domain/ProductCategory/GetProductCategoryService.php new file mode 100644 index 0000000000..ae03e4dd81 --- /dev/null +++ b/backend/app/Services/Domain/ProductCategory/GetProductCategoryService.php @@ -0,0 +1,47 @@ +productCategoryRepository + ->loadRelation(new Relationship( + domainObject: ProductDomainObject::class, + orderAndDirections: [ + new OrderAndDirection( + order: ProductCategoryDomainObjectAbstract::ORDER, + ), + ], + )) + ->findFirstWhere( + where: [ + 'id' => $categoryId, + 'event_id' => $eventId, + ] + ); + + if (!$category) { + throw new ResourceNotFoundException( + __('The product category with ID :id was not found.', ['id' => $categoryId]) + ); + } + + return $category; + } +} diff --git a/backend/app/Services/Domain/PromoCode/CreatePromoCodeService.php b/backend/app/Services/Domain/PromoCode/CreatePromoCodeService.php index 9e5609ce49..d8cdaa5ec5 100644 --- a/backend/app/Services/Domain/PromoCode/CreatePromoCodeService.php +++ b/backend/app/Services/Domain/PromoCode/CreatePromoCodeService.php @@ -9,30 +9,30 @@ use HiEvents\Helper\DateHelper; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; -use HiEvents\Services\Domain\Ticket\EventTicketValidationService; -use HiEvents\Services\Domain\Ticket\Exception\UnrecognizedTicketIdException; +use HiEvents\Services\Domain\Product\EventProductValidationService; +use HiEvents\Services\Domain\Product\Exception\UnrecognizedProductIdException; class CreatePromoCodeService { public function __construct( - private readonly PromoCodeRepositoryInterface $promoCodeRepository, - private readonly EventTicketValidationService $eventTicketValidationService, - private readonly EventRepositoryInterface $eventRepository, + private readonly PromoCodeRepositoryInterface $promoCodeRepository, + private readonly EventProductValidationService $eventProductValidationService, + private readonly EventRepositoryInterface $eventRepository, ) { } /** * @throws ResourceConflictException - * @throws UnrecognizedTicketIdException + * @throws UnrecognizedProductIdException */ public function createPromoCode(PromoCodeDomainObject $promoCode): PromoCodeDomainObject { $this->checkForDuplicateCode($promoCode); - if (!empty($promoCode->getApplicableTicketIds())) { - $this->eventTicketValidationService->validateTicketIds( - ticketIds: $promoCode->getApplicableTicketIds(), + if (!empty($promoCode->getApplicableProductIds())) { + $this->eventProductValidationService->validateProductIds( + productIds: $promoCode->getApplicableProductIds(), eventId: $promoCode->getEventId() ); } @@ -50,7 +50,7 @@ public function createPromoCode(PromoCodeDomainObject $promoCode): PromoCodeDoma ? DateHelper::convertToUTC($promoCode->getExpiryDate(), $event->getTimezone()) : null, PromoCodeDomainObjectAbstract::MAX_ALLOWED_USAGES => $promoCode->getMaxAllowedUsages(), - PromoCodeDomainObjectAbstract::APPLICABLE_TICKET_IDS => $promoCode->getApplicableTicketIds(), + PromoCodeDomainObjectAbstract::APPLICABLE_PRODUCT_IDS => $promoCode->getApplicableProductIds(), ]); } diff --git a/backend/app/Services/Domain/Question/CreateQuestionService.php b/backend/app/Services/Domain/Question/CreateQuestionService.php index c50672deb4..c5b2c79310 100644 --- a/backend/app/Services/Domain/Question/CreateQuestionService.php +++ b/backend/app/Services/Domain/Question/CreateQuestionService.php @@ -5,7 +5,7 @@ use HiEvents\DomainObjects\Generated\QuestionDomainObjectAbstract; use HiEvents\DomainObjects\QuestionDomainObject; use HiEvents\Repository\Interfaces\QuestionRepositoryInterface; -use HTMLPurifier; +use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService; use Illuminate\Database\DatabaseManager; use Throwable; @@ -14,7 +14,7 @@ class CreateQuestionService public function __construct( private readonly QuestionRepositoryInterface $questionRepository, private readonly DatabaseManager $databaseManager, - private readonly HTMLPurifier $purifier, + private readonly HtmlPurifierService $purifier, ) { } @@ -24,7 +24,7 @@ public function __construct( */ public function createQuestion( QuestionDomainObject $question, - array $ticketIds, + array $productIds, ): QuestionDomainObject { return $this->databaseManager->transaction(fn() => $this->questionRepository->create([ @@ -36,6 +36,6 @@ public function createQuestion( QuestionDomainObjectAbstract::OPTIONS => $question->getOptions(), QuestionDomainObjectAbstract::IS_HIDDEN => $question->getIsHidden(), QuestionDomainObjectAbstract::DESCRIPTION => $this->purifier->purify($question->getDescription()), - ], $ticketIds)); + ], $productIds)); } } diff --git a/backend/app/Services/Domain/Question/EditQuestionService.php b/backend/app/Services/Domain/Question/EditQuestionService.php index 1c29a8d3c5..11a393a093 100644 --- a/backend/app/Services/Domain/Question/EditQuestionService.php +++ b/backend/app/Services/Domain/Question/EditQuestionService.php @@ -3,10 +3,10 @@ namespace HiEvents\Services\Domain\Question; use HiEvents\DomainObjects\Generated\QuestionDomainObjectAbstract; +use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\QuestionDomainObject; -use HiEvents\DomainObjects\TicketDomainObject; use HiEvents\Repository\Interfaces\QuestionRepositoryInterface; -use HTMLPurifier; +use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService; use Illuminate\Database\DatabaseManager; use Throwable; @@ -15,7 +15,7 @@ class EditQuestionService public function __construct( private readonly QuestionRepositoryInterface $questionRepository, private readonly DatabaseManager $databaseManager, - private readonly HTMLPurifier $purifier, + private readonly HtmlPurifierService $purifier, ) { } @@ -25,10 +25,10 @@ public function __construct( */ public function editQuestion( QuestionDomainObject $question, - array $ticketIds, + array $productIds, ): QuestionDomainObject { - return $this->databaseManager->transaction(function () use ($question, $ticketIds) { + return $this->databaseManager->transaction(function () use ($question, $productIds) { $this->questionRepository->updateQuestion( questionId: $question->getId(), eventId: $question->getEventId(), @@ -42,11 +42,11 @@ public function editQuestion( QuestionDomainObjectAbstract::IS_HIDDEN => $question->getIsHidden(), QuestionDomainObjectAbstract::DESCRIPTION => $this->purifier->purify($question->getDescription()), ], - ticketIds: $ticketIds + productIds: $productIds ); return $this->questionRepository - ->loadRelation(TicketDomainObject::class) + ->loadRelation(ProductDomainObject::class) ->findById($question->getId()); }); } diff --git a/backend/app/Services/Domain/Report/AbstractReportService.php b/backend/app/Services/Domain/Report/AbstractReportService.php new file mode 100644 index 0000000000..24c3058a18 --- /dev/null +++ b/backend/app/Services/Domain/Report/AbstractReportService.php @@ -0,0 +1,49 @@ +eventRepository->findById($eventId); + $timezone = $event->getTimezone(); + + $endDate = Carbon::parse($endDate ?? now(), $timezone); + $startDate = Carbon::parse($startDate ?? $endDate->copy()->subDays(30), $timezone); + + $reportResults = $this->cache->remember( + key: $this->getCacheKey($eventId, $startDate, $endDate), + ttl: Carbon::now()->addSeconds(20), + callback: fn() => $this->queryBuilder->select( + $this->getSqlQuery($startDate, $endDate), + [ + 'event_id' => $eventId, + ] + ) + ); + + return collect($reportResults); + } + + abstract protected function getSqlQuery(Carbon $startDate, Carbon $endDate): string; + + protected function getCacheKey(int $eventId, ?Carbon $startDate, ?Carbon $endDate): string + { + return static::class . "$eventId.{$startDate?->toDateString()}.{$endDate?->toDateString()}"; + } +} diff --git a/backend/app/Services/Domain/Report/Exception/InvalidDateRange.php b/backend/app/Services/Domain/Report/Exception/InvalidDateRange.php new file mode 100644 index 0000000000..56f5e6baa3 --- /dev/null +++ b/backend/app/Services/Domain/Report/Exception/InvalidDateRange.php @@ -0,0 +1,10 @@ + App::make(ProductSalesReport::class), + ReportTypes::DAILY_SALES_REPORT => App::make(DailySalesReport::class), + ReportTypes::PROMO_CODES_REPORT => App::make(PromoCodesReport::class), + }; + } +} diff --git a/backend/app/Services/Domain/Report/Reports/DailySalesReport.php b/backend/app/Services/Domain/Report/Reports/DailySalesReport.php new file mode 100644 index 0000000000..ba396f3718 --- /dev/null +++ b/backend/app/Services/Domain/Report/Reports/DailySalesReport.php @@ -0,0 +1,37 @@ +toDateString(); + $endDateStr = $endDate->toDateString(); + + return <<format('Y-m-d H:i:s'); + $endDateString = $endDate->format('Y-m-d H:i:s'); + $completedStatus = OrderStatus::COMPLETED->name; + + 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; + + $translatedStringMap = [ + 'Expired' => __('Expired'), + 'Limit Reached' => __('Limit Reached'), + 'Deleted' => __('Deleted'), + 'Active' => __('Active'), + ]; + + return <<= '$startDateString' + AND o.created_at <= '$endDateString' + + GROUP BY + o.id, + o.promo_code_id, + o.promo_code, + o.total_gross, + o.email, + o.created_at + ), + promo_metrics AS ( + SELECT + COALESCE(pc.code, ot.promo_code) as promo_code, + COUNT(DISTINCT ot.order_id) as times_used, + COUNT(DISTINCT ot.email) as unique_customers, + COALESCE(SUM(ot.total_gross), 0) as total_gross_sales, + COALESCE(SUM(ot.original_total), 0) as total_before_discounts, + COALESCE(SUM(ot.original_total - ot.discounted_total), 0) as total_discount_amount, + MIN(ot.created_at AT TIME ZONE 'UTC') as first_used_at, + MAX(ot.created_at AT TIME ZONE 'UTC') as last_used_at, + pc.discount as configured_discount, + pc.discount_type, + pc.max_allowed_usages, + pc.expiry_date AT TIME ZONE 'UTC' as expiry_date, + CASE + WHEN pc.max_allowed_usages IS NOT NULL + THEN pc.max_allowed_usages - COUNT(ot.order_id)::integer + END as remaining_uses, + CASE + WHEN pc.expiry_date < CURRENT_TIMESTAMP THEN '{$translatedStringMap['Expired']}' + WHEN pc.max_allowed_usages IS NOT NULL AND COUNT(ot.order_id) >= pc.max_allowed_usages THEN '{$translatedStringMap['Limit Reached']}' + WHEN pc.deleted_at IS NOT NULL THEN '{$translatedStringMap['Deleted']}' + ELSE '{$translatedStringMap['Active']}' + END as status + FROM promo_codes pc + LEFT JOIN order_totals ot ON pc.id = ot.promo_code_id + WHERE + pc.deleted_at IS NULL + AND pc.event_id = :event_id + GROUP BY + pc.id, + COALESCE(pc.code, ot.promo_code), + pc.discount, + pc.discount_type, + pc.max_allowed_usages, + pc.expiry_date, + pc.deleted_at + ) + SELECT + promo_code, + times_used, + unique_customers, + configured_discount, + discount_type, + total_gross_sales, + total_before_discounts, + total_discount_amount, + first_used_at, + last_used_at, + max_allowed_usages, + remaining_uses, + expiry_date, + status + FROM promo_metrics + ORDER BY + total_gross_sales DESC, + promo_code; + SQL; + } +} diff --git a/backend/app/Services/Domain/Tax/DTO/TaxAndTicketAssociateParams.php b/backend/app/Services/Domain/Tax/DTO/TaxAndProductAssociateParams.php similarity index 71% rename from backend/app/Services/Domain/Tax/DTO/TaxAndTicketAssociateParams.php rename to backend/app/Services/Domain/Tax/DTO/TaxAndProductAssociateParams.php index cd645e87b9..f3b2b5fa70 100644 --- a/backend/app/Services/Domain/Tax/DTO/TaxAndTicketAssociateParams.php +++ b/backend/app/Services/Domain/Tax/DTO/TaxAndProductAssociateParams.php @@ -2,10 +2,10 @@ namespace HiEvents\Services\Domain\Tax\DTO; -class TaxAndTicketAssociateParams +class TaxAndProductAssociateParams { public function __construct( - public readonly int $ticketId, + public readonly int $productId, public readonly int $accountId, public readonly array $taxAndFeeIds, ) diff --git a/backend/app/Services/Domain/Tax/TaxAndFeeCalculationService.php b/backend/app/Services/Domain/Tax/TaxAndFeeCalculationService.php index 641ad7d9a8..f3343269bf 100644 --- a/backend/app/Services/Domain/Tax/TaxAndFeeCalculationService.php +++ b/backend/app/Services/Domain/Tax/TaxAndFeeCalculationService.php @@ -4,8 +4,8 @@ use HiEvents\DomainObjects\Enums\TaxCalculationType; use HiEvents\DomainObjects\TaxAndFeesDomainObject; -use HiEvents\DomainObjects\TicketDomainObject; -use HiEvents\DomainObjects\TicketPriceDomainObject; +use HiEvents\DomainObjects\ProductDomainObject; +use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\Services\Domain\Tax\DTO\TaxCalculationResponse; use InvalidArgumentException; @@ -18,26 +18,26 @@ public function __construct(TaxAndFeeRollupService $taxRollupService) $this->taxRollupService = $taxRollupService; } - public function calculateTaxAndFeesForTicketPrice( - TicketDomainObject $ticket, - TicketPriceDomainObject $price, + public function calculateTaxAndFeesForProductPrice( + ProductDomainObject $product, + ProductPriceDomainObject $price, ): TaxCalculationResponse { - return $this->calculateTaxAndFeesForTicket($ticket, $price->getPrice()); + return $this->calculateTaxAndFeesForProduct($product, $price->getPrice()); } - public function calculateTaxAndFeesForTicket( - TicketDomainObject $ticket, - float $price, - int $quantity = 1 + public function calculateTaxAndFeesForProduct( + ProductDomainObject $product, + float $price, + int $quantity = 1 ): TaxCalculationResponse { $this->taxRollupService->resetRollUp(); - $fees = $ticket->getFees() + $fees = $product->getFees() ?->sum(fn($taxOrFee) => $this->calculateFee($taxOrFee, $price, $quantity)) ?: 0.00; - $taxFees = $ticket->getTaxRates() + $taxFees = $product->getTaxRates() ?->sum(fn($taxOrFee) => $this->calculateFee($taxOrFee, $price + $fees, $quantity)); return new TaxCalculationResponse( diff --git a/backend/app/Services/Domain/Tax/TaxAndTicketAssociationService.php b/backend/app/Services/Domain/Tax/TaxAndProductAssociationService.php similarity index 66% rename from backend/app/Services/Domain/Tax/TaxAndTicketAssociationService.php rename to backend/app/Services/Domain/Tax/TaxAndProductAssociationService.php index f59431faa3..05527137ee 100644 --- a/backend/app/Services/Domain/Tax/TaxAndTicketAssociationService.php +++ b/backend/app/Services/Domain/Tax/TaxAndProductAssociationService.php @@ -4,16 +4,16 @@ use Exception; use HiEvents\Exceptions\InvalidTaxOrFeeIdException; +use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use HiEvents\Repository\Interfaces\TaxAndFeeRepositoryInterface; -use HiEvents\Repository\Interfaces\TicketRepositoryInterface; -use HiEvents\Services\Domain\Tax\DTO\TaxAndTicketAssociateParams; +use HiEvents\Services\Domain\Tax\DTO\TaxAndProductAssociateParams; use Illuminate\Support\Collection; -readonly class TaxAndTicketAssociationService +readonly class TaxAndProductAssociationService { public function __construct( private TaxAndFeeRepositoryInterface $taxAndFeeRepository, - private TicketRepositoryInterface $ticketRepository, + private ProductRepositoryInterface $ticketRepository, ) { } @@ -21,7 +21,7 @@ public function __construct( /** * @throws Exception */ - public function addTaxesToTicket(TaxAndTicketAssociateParams $params): Collection + public function addTaxesToProduct(TaxAndProductAssociateParams $params): Collection { $taxesAndFees = $this->taxAndFeeRepository->findWhereIn( field: 'id', @@ -36,7 +36,7 @@ public function addTaxesToTicket(TaxAndTicketAssociateParams $params): Collectio throw new InvalidTaxOrFeeIdException(__('One or more tax IDs are invalid')); } - $this->ticketRepository->addTaxesAndFeesToTicket($params->ticketId, $params->taxAndFeeIds); + $this->ticketRepository->addTaxesAndFeesToProduct($params->productId, $params->taxAndFeeIds); return $taxesAndFees; } diff --git a/backend/app/Services/Domain/Ticket/AvailableTicketQuantitiesFetchService.php b/backend/app/Services/Domain/Ticket/AvailableTicketQuantitiesFetchService.php deleted file mode 100644 index e3cca4ac33..0000000000 --- a/backend/app/Services/Domain/Ticket/AvailableTicketQuantitiesFetchService.php +++ /dev/null @@ -1,173 +0,0 @@ -config->get('app.homepage_ticket_quantities_cache_ttl')) { - $cachedData = $this->getDataFromCache($eventId); - if ($cachedData) { - return $cachedData; - } - } - - $capacities = $this->capacityAssignmentRepository - ->loadRelation(TicketDomainObject::class) - ->findWhere([ - 'event_id' => $eventId, - 'applies_to' => CapacityAssignmentAppliesTo::TICKETS->name, - 'status' => CapacityAssignmentStatus::ACTIVE->name, - ]); - - $reservedTicketQuantities = $this->fetchReservedTicketQuantities($eventId); - $ticketCapacities = $this->calculateTicketCapacities($capacities); - - $quantities = $reservedTicketQuantities->map(function (AvailableTicketQuantitiesDTO $dto) use ($ticketCapacities) { - $ticketId = $dto->ticket_id; - if (isset($ticketCapacities[$ticketId])) { - $dto->quantity_available = min(array_merge([$dto->quantity_available], $ticketCapacities[$ticketId]->map->getAvailableCapacity()->toArray())); - $dto->capacities = $ticketCapacities[$ticketId]; - } - - return $dto; - }); - - $finalData = new AvailableTicketQuantitiesResponseDTO( - ticketQuantities: $quantities, - capacities: $capacities - ); - - if (!$ignoreCache && $this->config->get('app.homepage_ticket_quantities_cache_ttl')) { - $this->cache->put($this->getCacheKey($eventId), $finalData, $this->config->get('app.homepage_ticket_quantities_cache_ttl')); - } - - return $finalData; - } - - private function fetchReservedTicketQuantities(int $eventId): Collection - { - $result = $this->db->select(<< NOW() - AND orders.deleted_at IS NULL - THEN order_items.quantity - ELSE 0 - END - ) AS quantity_reserved - FROM tickets - JOIN ticket_prices ON tickets.id = ticket_prices.ticket_id - LEFT JOIN order_items ON order_items.ticket_id = tickets.id - AND order_items.ticket_price_id = ticket_prices.id - LEFT JOIN orders ON orders.id = order_items.order_id - AND orders.event_id = tickets.event_id - AND orders.deleted_at IS NULL - WHERE - tickets.event_id = :eventId - AND tickets.deleted_at IS NULL - AND ticket_prices.deleted_at IS NULL - GROUP BY tickets.id, ticket_prices.id - ) - SELECT - tickets.id AS ticket_id, - ticket_prices.id AS ticket_price_id, - tickets.title AS ticket_title, - ticket_prices.label AS price_label, - ticket_prices.initial_quantity_available, - ticket_prices.quantity_sold, - COALESCE( - ticket_prices.initial_quantity_available - - ticket_prices.quantity_sold - - COALESCE(reserved_quantities.quantity_reserved, 0), - 0) AS quantity_available, - COALESCE(reserved_quantities.quantity_reserved, 0) AS quantity_reserved, - CASE WHEN ticket_prices.initial_quantity_available IS NULL - THEN TRUE - ELSE FALSE - END AS unlimited_quantity_available - FROM tickets - JOIN ticket_prices ON tickets.id = ticket_prices.ticket_id - LEFT JOIN reserved_quantities ON tickets.id = reserved_quantities.ticket_id - AND ticket_prices.id = reserved_quantities.ticket_price_id - WHERE - tickets.event_id = :eventId - AND tickets.deleted_at IS NULL - AND ticket_prices.deleted_at IS NULL - GROUP BY tickets.id, ticket_prices.id, reserved_quantities.quantity_reserved; - SQL, [ - 'eventId' => $eventId, - 'reserved' => OrderStatus::RESERVED->name - ]); - - return collect($result)->map(fn($row) => AvailableTicketQuantitiesDTO::fromArray([ - 'ticket_id' => $row->ticket_id, - 'price_id' => $row->ticket_price_id, - 'ticket_title' => $row->ticket_title, - 'price_label' => $row->price_label, - 'quantity_available' => $row->unlimited_quantity_available ? Constants::INFINITE : $row->quantity_available, - 'initial_quantity_available' => $row->initial_quantity_available, - 'quantity_reserved' => $row->quantity_reserved, - 'capacities' => new Collection(), - ])); - } - - /** - * @param Collection $capacities - */ - private function calculateTicketCapacities(Collection $capacities): array - { - $ticketCapacities = []; - foreach ($capacities as $capacity) { - foreach ($capacity->getTickets() as $ticket) { - $ticketId = $ticket->getId(); - if (!isset($ticketCapacities[$ticketId])) { - $ticketCapacities[$ticketId] = collect(); - } - - $ticketCapacities[$ticketId]->push($capacity); - } - } - - return $ticketCapacities; - } - - private function getDataFromCache(int $eventId): ?AvailableTicketQuantitiesResponseDTO - { - return $this->cache->get($this->getCacheKey($eventId)); - } - - private function getCacheKey(int $eventId): string - { - return "event.$eventId.available_ticket_quantities"; - } -} diff --git a/backend/app/Services/Domain/Ticket/CreateTicketService.php b/backend/app/Services/Domain/Ticket/CreateTicketService.php deleted file mode 100644 index dc6764807c..0000000000 --- a/backend/app/Services/Domain/Ticket/CreateTicketService.php +++ /dev/null @@ -1,118 +0,0 @@ -databaseManager->transaction(function () use ($accountId, $taxAndFeeIds, $ticket) { - $persistedTicket = $this->persistTicket($ticket); - - if ($taxAndFeeIds) { - $this->associateTaxesAndFees($persistedTicket, $taxAndFeeIds, $accountId); - } - - return $this->createTicketPrices($persistedTicket, $ticket); - }); - } - - private function persistTicket(TicketDomainObject $ticketsData): TicketDomainObject - { - $event = $this->eventRepository->findById($ticketsData->getEventId()); - - return $this->ticketRepository->create([ - 'title' => $ticketsData->getTitle(), - 'type' => $ticketsData->getType(), - 'order' => $ticketsData->getOrder(), - 'sale_start_date' => $ticketsData->getSaleStartDate() - ? DateHelper::convertToUTC($ticketsData->getSaleStartDate(), $event->getTimezone()) - : null, - 'sale_end_date' => $ticketsData->getSaleEndDate() - ? DateHelper::convertToUTC($ticketsData->getSaleEndDate(), $event->getTimezone()) - : null, - 'max_per_order' => $ticketsData->getMaxPerOrder(), - 'description' => $this->purifier->purify($ticketsData->getDescription()), - 'min_per_order' => $ticketsData->getMinPerOrder(), - 'is_hidden' => $ticketsData->getIsHidden(), - 'hide_before_sale_start_date' => $ticketsData->getHideBeforeSaleStartDate(), - 'hide_after_sale_end_date' => $ticketsData->getHideAfterSaleEndDate(), - 'hide_when_sold_out' => $ticketsData->getHideWhenSoldOut(), - 'start_collapsed' => $ticketsData->getStartCollapsed(), - 'show_quantity_remaining' => $ticketsData->getShowQuantityRemaining(), - 'is_hidden_without_promo_code' => $ticketsData->getIsHiddenWithoutPromoCode(), - 'event_id' => $ticketsData->getEventId(), - ]); - } - - /** - * @throws Exception - */ - private function createTicketTaxesAndFees( - TicketDomainObject $ticket, - array $taxAndFeeIds, - int $accountId, - ): Collection - { - return $this->taxAndTicketAssociationService->addTaxesToTicket( - new TaxAndTicketAssociateParams( - ticketId: $ticket->getId(), - accountId: $accountId, - taxAndFeeIds: $taxAndFeeIds, - ), - ); - } - - /** - * @throws Exception - */ - private function associateTaxesAndFees(TicketDomainObject $persistedTicket, array $taxAndFeeIds, int $accountId): void - { - $persistedTicket->setTaxAndFees($this->createTicketTaxesAndFees( - ticket: $persistedTicket, - taxAndFeeIds: $taxAndFeeIds, - accountId: $accountId, - )); - } - - private function createTicketPrices(TicketDomainObject $persistedTicket, TicketDomainObject $ticket): TicketDomainObject - { - $prices = $this->priceCreateService->createPrices( - ticketId: $persistedTicket->getId(), - prices: $ticket->getTicketPrices(), - event: $this->eventRepository->findById($ticket->getEventId()), - ); - - return $persistedTicket->setTicketPrices($prices); - } -} diff --git a/backend/app/Services/Domain/Ticket/DTO/CreateTicketDTO.php b/backend/app/Services/Domain/Ticket/DTO/CreateTicketDTO.php deleted file mode 100644 index d2008ba496..0000000000 --- a/backend/app/Services/Domain/Ticket/DTO/CreateTicketDTO.php +++ /dev/null @@ -1,10 +0,0 @@ -ticketRepository->findWhere([ - 'event_id' => $eventId, - ])->map(fn(TicketDomainObject $ticket) => $ticket->getId()) - ->toArray(); - - $invalidTicketIds = array_diff($ticketIds, $validTicketIds); - - if (!empty($invalidTicketIds)) { - throw new UnrecognizedTicketIdException( - __('Invalid ticket ids: :ids', ['ids' => implode(', ', $invalidTicketIds)]) - ); - } - } -} diff --git a/backend/app/Services/Domain/Ticket/Exception/UnrecognizedTicketIdException.php b/backend/app/Services/Domain/Ticket/Exception/UnrecognizedTicketIdException.php deleted file mode 100644 index ed1ae38f98..0000000000 --- a/backend/app/Services/Domain/Ticket/Exception/UnrecognizedTicketIdException.php +++ /dev/null @@ -1,10 +0,0 @@ - $tickets - * @param PromoCodeDomainObject|null $promoCode - * @param bool $hideSoldOutTickets - * @return Collection - */ - public function filter( - Collection $tickets, - ?PromoCodeDomainObject $promoCode = null, - bool $hideSoldOutTickets = true, - ): Collection - { - if ($tickets->isEmpty()) { - return $tickets; - } - - $ticketQuantities = $this - ->fetchAvailableTicketQuantitiesService - ->getAvailableTicketQuantities($tickets->first()->getEventId()); - - return $tickets - ->map(fn(TicketDomainObject $ticket) => $this->processTicket($ticket, $ticketQuantities->ticketQuantities, $promoCode)) - ->reject(fn(TicketDomainObject $ticket) => $this->filterTicket($ticket, $promoCode, $hideSoldOutTickets)) - ->each(fn(TicketDomainObject $ticket) => $this->processTicketPrices($ticket, $hideSoldOutTickets)); - } - - private function isHiddenByPromoCode(TicketDomainObject $ticket, ?PromoCodeDomainObject $promoCode): bool - { - return $ticket->getIsHiddenWithoutPromoCode() && !( - $promoCode - && $promoCode->appliesToTicket($ticket) - ); - } - - private function shouldTicketBeDiscounted(?PromoCodeDomainObject $promoCode, TicketDomainObject $ticket): bool - { - if ($ticket->isDonationType() || $ticket->isFreeType()) { - return false; - } - - return $promoCode - && $promoCode->isDiscountCode() - && $promoCode->appliesToTicket($ticket); - } - - /** - * @param PromoCodeDomainObject|null $promoCode - * @param TicketDomainObject $ticket - * @param Collection $ticketQuantities - * @return TicketDomainObject - */ - private function processTicket( - TicketDomainObject $ticket, - Collection $ticketQuantities, - ?PromoCodeDomainObject $promoCode = null, - ): TicketDomainObject - { - if ($this->shouldTicketBeDiscounted($promoCode, $ticket)) { - $ticket->getTicketPrices()?->each(function (TicketPriceDomainObject $price) use ($ticket, $promoCode) { - $price->setPriceBeforeDiscount($price->getPrice()); - $price->setPrice($this->ticketPriceService->getIndividualPrice($ticket, $price, $promoCode)); - }); - } - - $ticket->getTicketPrices()?->map(function (TicketPriceDomainObject $price) use ($ticketQuantities) { - $availableQuantity = $ticketQuantities->where('price_id', $price->getId())->first()?->quantity_available; - $availableQuantity = $availableQuantity === Constants::INFINITE ? null : $availableQuantity; - $price->setQuantityAvailable( - max($availableQuantity, 0) - ); - }); - - // If there is a capacity assigned to the ticket, we set the capacity to capacity available qty, or the sum of all - // ticket prices qty, whichever is lower - $ticketQuantities->each(function (AvailableTicketQuantitiesDTO $quantity) use ($ticket) { - if ($quantity->capacities !== null && $quantity->capacities->isNotEmpty() && $quantity->ticket_id === $ticket->getId()) { - $ticket->setQuantityAvailable( - $quantity->capacities->min(fn(CapacityAssignmentDomainObject $capacity) => $capacity->getAvailableCapacity()) - ); - } - }); - - return $ticket; - } - - private function filterTicket( - TicketDomainObject $ticket, - ?PromoCodeDomainObject $promoCode = null, - bool $hideSoldOutTickets = true, - ): bool - { - $hidden = false; - - if ($this->isHiddenByPromoCode($ticket, $promoCode)) { - $ticket->setOffSaleReason(__('Ticket is hidden without promo code')); - $hidden = true; - } - - if ($ticket->isSoldOut() && $ticket->getHideWhenSoldOut()) { - $ticket->setOffSaleReason(__('Ticket is sold out')); - $hidden = true; - } - - if ($ticket->isBeforeSaleStartDate() && $ticket->getHideBeforeSaleStartDate()) { - $ticket->setOffSaleReason(__('Ticket is before sale start date')); - $hidden = true; - } - - if ($ticket->isAfterSaleEndDate() && $ticket->getHideAfterSaleEndDate()) { - $ticket->setOffSaleReason(__('Ticket is after sale end date')); - $hidden = true; - } - - if ($ticket->getIsHidden()) { - $ticket->setOffSaleReason(__('Ticket is hidden')); - $hidden = true; - } - - return $hidden && $hideSoldOutTickets; - } - - private function processTicketPrice(TicketDomainObject $ticket, TicketPriceDomainObject $price): void - { - $taxAndFees = $this->taxCalculationService - ->calculateTaxAndFeesForTicketPrice($ticket, $price); - - $price - ->setTaxTotal(Currency::round($taxAndFees->taxTotal)) - ->setFeeTotal(Currency::round($taxAndFees->feeTotal)); - - $price->setIsAvailable($this->getPriceAvailability($price, $ticket)); - } - - private function filterTicketPrice( - TicketDomainObject $ticket, - TicketPriceDomainObject $price, - bool $hideSoldOutTickets = true - ): bool - { - $hidden = false; - - if (!$ticket->isTieredType()) { - return false; - } - - if ($price->isBeforeSaleStartDate() && $ticket->getHideBeforeSaleStartDate()) { - $price->setOffSaleReason(__('Price is before sale start date')); - $hidden = true; - } - - if ($price->isAfterSaleEndDate() && $ticket->getHideAfterSaleEndDate()) { - $price->setOffSaleReason(__('Price is after sale end date')); - $hidden = true; - } - - if ($price->isSoldOut() && $ticket->getHideWhenSoldOut()) { - $price->setOffSaleReason(__('Price is sold out')); - $hidden = true; - } - - if ($price->getIsHidden()) { - $price->setOffSaleReason(__('Price is hidden')); - $hidden = true; - } - - return $hidden && $hideSoldOutTickets; - } - - private function processTicketPrices(TicketDomainObject $ticket, bool $hideSoldOutTickets = true): void - { - $ticket->setTicketPrices( - $ticket->getTicketPrices() - ?->each(fn(TicketPriceDomainObject $price) => $this->processTicketPrice($ticket, $price)) - ->reject(fn(TicketPriceDomainObject $price) => $this->filterTicketPrice($ticket, $price, $hideSoldOutTickets)) - ); - } - - /** - * For non-tiered tickets, we can inherit the availability of the ticket. - * - * @param TicketPriceDomainObject $price - * @param TicketDomainObject $ticket - * @return bool - */ - private function getPriceAvailability(TicketPriceDomainObject $price, TicketDomainObject $ticket): bool - { - if ($ticket->isTieredType()) { - return !$price->isSoldOut() - && !$price->isBeforeSaleStartDate() - && !$price->isAfterSaleEndDate() - && !$price->getIsHidden(); - } - - return !$ticket->isSoldOut() - && !$ticket->isBeforeSaleStartDate() - && !$ticket->isAfterSaleEndDate() - && !$ticket->getIsHidden(); - } -} diff --git a/backend/app/Services/Domain/Ticket/TicketPriceService.php b/backend/app/Services/Domain/Ticket/TicketPriceService.php deleted file mode 100644 index b2d652f160..0000000000 --- a/backend/app/Services/Domain/Ticket/TicketPriceService.php +++ /dev/null @@ -1,77 +0,0 @@ -getPrice($ticket, new OrderTicketPriceDTO( - quantity: 1, - price_id: $price->getId(), - ), $promoCode)->price; - } - - public function getPrice( - TicketDomainObject $ticket, - OrderTicketPriceDTO $ticketOrderDetail, - ?PromoCodeDomainObject $promoCode - ): PriceDTO - { - $price = $this->determineTicketPrice($ticket, $ticketOrderDetail); - - if ($ticket->getType() === TicketType::FREE->name) { - return new PriceDTO(0.00); - } - - if ($ticket->getType() === TicketType::DONATION->name) { - return new PriceDTO($price); - } - - if (!$promoCode || !$promoCode->appliesToTicket($ticket)) { - return new PriceDTO($price); - } - - if ($promoCode->getDiscountType() === PromoCodeDiscountTypeEnum::NONE->name) { - return new PriceDTO($price); - } - - if ($promoCode->isFixedDiscount()) { - $discountPrice = Currency::round($price - $promoCode->getDiscount()); - } elseif ($promoCode->isPercentageDiscount()) { - $discountPrice = Currency::round( - $price - ($price * ($promoCode->getDiscount() / 100)) - ); - } else { - $discountPrice = $price; - } - - return new PriceDTO( - price: max(0, $discountPrice), - price_before_discount: $price - ); - } - - private function determineTicketPrice(TicketDomainObject $ticket, OrderTicketPriceDTO $ticketOrderDetails): float - { - return match ($ticket->getType()) { - TicketType::DONATION->name => max($ticket->getPrice(), $ticketOrderDetails->price), - TicketType::PAID->name => $ticket->getPrice(), - TicketType::FREE->name => 0.00, - TicketType::TIERED->name => $ticket->getPriceById($ticketOrderDetails->price_id)?->getPrice() - }; - } -} diff --git a/backend/app/Services/Handlers/Attendee/DTO/EditAttendeeDTO.php b/backend/app/Services/Handlers/Attendee/DTO/EditAttendeeDTO.php deleted file mode 100644 index 79b486591e..0000000000 --- a/backend/app/Services/Handlers/Attendee/DTO/EditAttendeeDTO.php +++ /dev/null @@ -1,20 +0,0 @@ -databaseManager->transaction(function () use ($editAttendeeDTO) { - $this->validateTicketId($editAttendeeDTO); - - $attendee = $this->getAttendee($editAttendeeDTO); - - $this->adjustTicketQuantities($attendee, $editAttendeeDTO); - - return $this->updateAttendee($editAttendeeDTO); - }); - } - - private function adjustTicketQuantities(AttendeeDomainObject $attendee, EditAttendeeDTO $editAttendeeDTO): void - { - if ($attendee->getTicketPriceId() !== $editAttendeeDTO->ticket_price_id) { - $this->ticketQuantityService->decreaseQuantitySold($attendee->getTicketPriceId()); - $this->ticketQuantityService->increaseQuantitySold($editAttendeeDTO->ticket_price_id); - } - } - - private function updateAttendee(EditAttendeeDTO $editAttendeeDTO): AttendeeDomainObject - { - return $this->attendeeRepository->updateByIdWhere($editAttendeeDTO->attendee_id, [ - 'first_name' => $editAttendeeDTO->first_name, - 'last_name' => $editAttendeeDTO->last_name, - 'email' => $editAttendeeDTO->email, - 'ticket_id' => $editAttendeeDTO->ticket_id, - 'ticket_price_id' => $editAttendeeDTO->ticket_price_id, - ], [ - 'event_id' => $editAttendeeDTO->event_id, - ]); - } - - /** - * @throws ValidationException - * @throws NoTicketsAvailableException - */ - private function validateTicketId(EditAttendeeDTO $editAttendeeDTO): void - { - $ticket = $this->ticketRepository - ->loadRelation(TicketPriceDomainObject::class) - ->findFirstWhere([ - TicketDomainObjectAbstract::ID => $editAttendeeDTO->ticket_id, - ]); - - if ($ticket->getEventId() !== $editAttendeeDTO->event_id) { - throw ValidationException::withMessages([ - 'ticket_id' => __('Ticket ID is not valid'), - ]); - } - - $ticketPriceIds = $ticket->getTicketPrices()->map(fn($ticketPrice) => $ticketPrice->getId())->toArray(); - if (!in_array($editAttendeeDTO->ticket_price_id, $ticketPriceIds, true)) { - throw ValidationException::withMessages([ - 'ticket_price_id' => __('Ticket price ID is not valid'), - ]); - } - - $availableQuantity = $this->ticketRepository->getQuantityRemainingForTicketPrice( - ticketId: $editAttendeeDTO->ticket_id, - ticketPriceId: $ticket->getType() === TicketType::TIERED->name - ? $editAttendeeDTO->ticket_price_id - : $ticket->getTicketPrices()->first()->getId(), - ); - - if ($availableQuantity <= 0) { - throw new NoTicketsAvailableException( - __('There are no tickets available. If you would like to assign this ticket to this attendee, please adjust the ticket\'s available quantity.') - ); - } - } - - /** - * @throws ValidationException - */ - private function getAttendee(EditAttendeeDTO $editAttendeeDTO): AttendeeDomainObject - { - $attendee = $this->attendeeRepository->findFirstWhere([ - AttendeeDomainObjectAbstract::EVENT_ID => $editAttendeeDTO->event_id, - AttendeeDomainObjectAbstract::ID => $editAttendeeDTO->attendee_id, - ]); - - if ($attendee === null) { - throw ValidationException::withMessages([ - 'attendee_id' => __('Attendee ID is not valid'), - ]); - } - - return $attendee; - } -} diff --git a/backend/app/Services/Handlers/CheckInList/Public/DTO/CreateAttendeeCheckInPublicDTO.php b/backend/app/Services/Handlers/CheckInList/Public/DTO/CreateAttendeeCheckInPublicDTO.php deleted file mode 100644 index e915beb31a..0000000000 --- a/backend/app/Services/Handlers/CheckInList/Public/DTO/CreateAttendeeCheckInPublicDTO.php +++ /dev/null @@ -1,16 +0,0 @@ -orderRepository - ->findFirstWhere([ - OrderDomainObjectAbstract::EVENT_ID => $cancelOrderDTO->eventId, - OrderDomainObjectAbstract::ID => $cancelOrderDTO->orderId, - ]); - - if (!$order) { - throw new ResourceNotFoundException(__('Order not found')); - } - - if ($order->isOrderCancelled()) { - throw new ResourceConflictException(__('Order already cancelled')); - } - - $this->orderCancelService->cancelOrder($order); - - return $this->orderRepository->findById($order->getId()); - } -} diff --git a/backend/app/Services/Handlers/Order/DTO/CompleteOrderAttendeeDTO.php b/backend/app/Services/Handlers/Order/DTO/CompleteOrderAttendeeDTO.php deleted file mode 100644 index 9d7bb32b14..0000000000 --- a/backend/app/Services/Handlers/Order/DTO/CompleteOrderAttendeeDTO.php +++ /dev/null @@ -1,21 +0,0 @@ -prices->map(fn(TicketPriceDTO $price) => TicketPriceDomainObject::hydrateFromArray([ - TicketPriceDomainObjectAbstract::PRICE => $ticketsData->type === TicketType::FREE ? 0.00 : $price->price, - TicketPriceDomainObjectAbstract::LABEL => $price->label, - TicketPriceDomainObjectAbstract::SALE_START_DATE => $price->sale_start_date, - TicketPriceDomainObjectAbstract::SALE_END_DATE => $price->sale_end_date, - TicketPriceDomainObjectAbstract::INITIAL_QUANTITY_AVAILABLE => $price->initial_quantity_available, - TicketPriceDomainObjectAbstract::IS_HIDDEN => $price->is_hidden, - ])); - - return $this->ticketCreateService->createTicket( - ticket: (new TicketDomainObject()) - ->setTitle($ticketsData->title) - ->setType($ticketsData->type->name) - ->setOrder($ticketsData->order) - ->setSaleStartDate($ticketsData->sale_start_date) - ->setSaleEndDate($ticketsData->sale_end_date) - ->setMaxPerOrder($ticketsData->max_per_order) - ->setDescription($ticketsData->description) - ->setMinPerOrder($ticketsData->min_per_order) - ->setIsHidden($ticketsData->is_hidden) - ->setHideBeforeSaleStartDate($ticketsData->hide_before_sale_start_date) - ->setHideAfterSaleEndDate($ticketsData->hide_after_sale_end_date) - ->setHideWhenSoldOut($ticketsData->hide_when_sold_out) - ->setStartCollapsed($ticketsData->start_collapsed) - ->setShowQuantityRemaining($ticketsData->show_quantity_remaining) - ->setIsHiddenWithoutPromoCode($ticketsData->is_hidden_without_promo_code) - ->setTicketPrices($ticketPrices) - ->setEventId($ticketsData->event_id), - accountId: $ticketsData->account_id, - taxAndFeeIds: $ticketsData->tax_and_fee_ids, - ); - } -} diff --git a/backend/app/Services/Handlers/Ticket/DTO/UpsertTicketDTO.php b/backend/app/Services/Handlers/Ticket/DTO/UpsertTicketDTO.php deleted file mode 100644 index b19e4d9798..0000000000 --- a/backend/app/Services/Handlers/Ticket/DTO/UpsertTicketDTO.php +++ /dev/null @@ -1,42 +0,0 @@ -databaseManager->transaction(function () use ($ticketId, $eventId) { - $this->deleteTicket($ticketId, $eventId); - }); - } - - /** - * @throws CannotDeleteEntityException - */ - private function deleteTicket(int $ticketId, int $eventId): void - { - $attendees = $this->attendeeRepository->findWhere( - [ - AttendeeDomainObjectAbstract::EVENT_ID => $eventId, - AttendeeDomainObjectAbstract::TICKET_ID => $ticketId, - ] - ); - - if ($attendees->count() > 0) { - throw new CannotDeleteEntityException( - __('You cannot delete this ticket because it has orders associated with it. You can hide it instead.') - ); - } - - $this->ticketRepository->deleteWhere( - [ - TicketDomainObjectAbstract::EVENT_ID => $eventId, - TicketDomainObjectAbstract::ID => $ticketId, - ] - ); - - $this->ticketPriceRepository->deleteWhere( - [ - TicketPriceDomainObjectAbstract::TICKET_ID => $ticketId, - ] - ); - - $this->logger->info(sprintf('Ticket %d was deleted from event %d', $ticketId, $eventId), [ - 'ticketId' => $ticketId, - 'eventId' => $eventId, - ]); - } -} diff --git a/backend/app/Services/Handlers/Ticket/EditTicketHandler.php b/backend/app/Services/Handlers/Ticket/EditTicketHandler.php deleted file mode 100644 index fb0e824393..0000000000 --- a/backend/app/Services/Handlers/Ticket/EditTicketHandler.php +++ /dev/null @@ -1,139 +0,0 @@ -databaseManager->transaction(function () use ($ticketsData) { - $where = [ - 'event_id' => $ticketsData->event_id, - 'id' => $ticketsData->ticket_id, - ]; - - $ticket = $this->updateTicket($ticketsData, $where); - - $this->addTaxes($ticket, $ticketsData); - - $this->priceUpdateService->updatePrices( - $ticket, - $ticketsData, - $ticket->getTicketPrices(), - $this->eventRepository->findById($ticketsData->event_id) - ); - - return $this->ticketRepository - ->loadRelation(TicketPriceDomainObject::class) - ->findById($ticket->getId()); - }); - } - - /** - * @throws CannotChangeTicketTypeException - */ - private function updateTicket(UpsertTicketDTO $ticketsData, array $where): TicketDomainObject - { - $event = $this->eventRepository->findById($ticketsData->event_id); - - $this->validateChangeInTicketType($ticketsData); - - $this->ticketRepository->updateWhere( - attributes: [ - 'title' => $ticketsData->title, - 'type' => $ticketsData->type->name, - 'order' => $ticketsData->order, - 'sale_start_date' => $ticketsData->sale_start_date - ? DateHelper::convertToUTC($ticketsData->sale_start_date, $event->getTimezone()) - : null, - 'sale_end_date' => $ticketsData->sale_end_date - ? DateHelper::convertToUTC($ticketsData->sale_end_date, $event->getTimezone()) - : null, - 'max_per_order' => $ticketsData->max_per_order, - 'description' => $this->purifier->purify($ticketsData->description), - 'min_per_order' => $ticketsData->min_per_order, - 'is_hidden' => $ticketsData->is_hidden, - 'hide_before_sale_start_date' => $ticketsData->hide_before_sale_start_date, - 'hide_after_sale_end_date' => $ticketsData->hide_after_sale_end_date, - 'hide_when_sold_out' => $ticketsData->hide_when_sold_out, - 'start_collapsed' => $ticketsData->start_collapsed, - 'show_quantity_remaining' => $ticketsData->show_quantity_remaining, - 'is_hidden_without_promo_code' => $ticketsData->is_hidden_without_promo_code, - ], - where: $where - ); - - return $this->ticketRepository - ->loadRelation(TicketPriceDomainObject::class) - ->findFirstWhere($where); - } - - /** - * @throws Exception - */ - private function addTaxes(TicketDomainObject $ticket, UpsertTicketDTO $ticketsData): void - { - $this->taxAndTicketAssociationService->addTaxesToTicket( - new TaxAndTicketAssociateParams( - ticketId: $ticket->getId(), - accountId: $ticketsData->account_id, - taxAndFeeIds: $ticketsData->tax_and_fee_ids, - ) - ); - } - - /** - * @throws CannotChangeTicketTypeException - * @todo - We should probably check reserved tickets here as well - */ - private function validateChangeInTicketType(UpsertTicketDTO $ticketsData): void - { - $ticket = $this->ticketRepository - ->loadRelation(TicketPriceDomainObject::class) - ->findById($ticketsData->ticket_id); - - $quantitySold = $ticket->getTicketPrices() - ->sum(fn(TicketPriceDomainObject $price) => $price->getQuantitySold()); - - if ($ticket->getType() !== $ticketsData->type->name && $quantitySold > 0) { - throw new CannotChangeTicketTypeException( - __('Ticket type cannot be changed as tickets have been registered for this type') - ); - } - } -} diff --git a/backend/app/Services/Handlers/Ticket/GetTicketsHandler.php b/backend/app/Services/Handlers/Ticket/GetTicketsHandler.php deleted file mode 100644 index 898e952d34..0000000000 --- a/backend/app/Services/Handlers/Ticket/GetTicketsHandler.php +++ /dev/null @@ -1,37 +0,0 @@ -ticketRepository - ->loadRelation(TicketPriceDomainObject::class) - ->loadRelation(TaxAndFeesDomainObject::class) - ->findByEventId($eventId, $queryParamsDTO); - - $filteredTickets = $this->ticketFilterService->filter( - tickets: $ticketPaginator->getCollection(), - hideSoldOutTickets: false, - ); - - $ticketPaginator->setCollection($filteredTickets); - - return $ticketPaginator; - } -} diff --git a/backend/app/Services/Handlers/Ticket/SortTicketsHandler.php b/backend/app/Services/Handlers/Ticket/SortTicketsHandler.php deleted file mode 100644 index 2506098568..0000000000 --- a/backend/app/Services/Handlers/Ticket/SortTicketsHandler.php +++ /dev/null @@ -1,41 +0,0 @@ -sortBy('order')->pluck('id')->toArray(); - - $ticketIdsResult = $this->ticketRepository->findWhere([ - 'event_id' => $eventId, - ]) - ->map(fn($ticket) => $ticket->getId()) - ->toArray(); - - // Check if the orderedTicketIds array exactly matches the ticket IDs from the database - $missingInOrdered = array_diff($ticketIdsResult, $orderedTicketIds); - $extraInOrdered = array_diff($orderedTicketIds, $ticketIdsResult); - - if (!empty($missingInOrdered) || !empty($extraInOrdered)) { - throw new ResourceConflictException( - __('The ordered ticket IDs must exactly match all tickets for the event without missing or extra IDs.') - ); - } - - $this->ticketRepository->sortTickets($eventId, $orderedTicketIds); - } -} diff --git a/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php b/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php new file mode 100644 index 0000000000..efefb4f44a --- /dev/null +++ b/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php @@ -0,0 +1,31 @@ +config = HTMLPurifier_Config::createDefault(); + + $cachePath = storage_path('app/htmlpurifier'); + File::ensureDirectoryExists($cachePath, 0755); + + $this->config->set('Cache.SerializerPath', $cachePath); + } + + public function purify(?string $html): string + { + if ($html === null) { + return ''; + } + + return $this->htmlPurifier->purify($html, $this->config); + } +} diff --git a/backend/app/Services/Infrastructure/Image/ImageStorageService.php b/backend/app/Services/Infrastructure/Image/ImageStorageService.php index e8f59b863b..db97807a0f 100644 --- a/backend/app/Services/Infrastructure/Image/ImageStorageService.php +++ b/backend/app/Services/Infrastructure/Image/ImageStorageService.php @@ -25,7 +25,14 @@ public function __construct( */ public function store(UploadedFile $image, string $imageType): ImageStorageResponseDTO { - $filename = Str::slug($image->getClientOriginalName()) . '-' . Str::random(5) . '.' . $image->getClientOriginalExtension(); + $filename = Str::slug( + title: str_ireplace( + search: '.' . $image->getClientOriginalExtension(), + replace: '', + subject: $image->getClientOriginalName() + ) + ) . '-' . Str::random(5) . '.' . $image->getClientOriginalExtension(); + $disk = $this->config->get('filesystems.public'); $path = $this->filesystemManager->disk($disk)->putFileAs( diff --git a/backend/app/Services/Infrastructure/Session/CheckoutSessionManagementService.php b/backend/app/Services/Infrastructure/Session/CheckoutSessionManagementService.php index 35d6fef2d8..792067f849 100644 --- a/backend/app/Services/Infrastructure/Session/CheckoutSessionManagementService.php +++ b/backend/app/Services/Infrastructure/Session/CheckoutSessionManagementService.php @@ -2,6 +2,7 @@ namespace HiEvents\Services\Infrastructure\Session; +use Illuminate\Config\Repository; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cookie; use Illuminate\Support\Str; @@ -14,7 +15,8 @@ class CheckoutSessionManagementService private ?string $sessionId = null; public function __construct( - private readonly Request $request, + private readonly Request $request, + private readonly Repository $config, ) { } @@ -43,6 +45,7 @@ public function getSessionCookie(): SymfonyCookie return Cookie::make( name: self::SESSION_IDENTIFIER, value: $this->getSessionId(), + domain: $this->config->get('session.domain') ?? '.' . $this->request->getHost(), secure: true, sameSite: 'None', ); diff --git a/backend/app/Services/Infrastructure/Webhook/WebhookDispatchService.php b/backend/app/Services/Infrastructure/Webhook/WebhookDispatchService.php new file mode 100644 index 0000000000..150454ade1 --- /dev/null +++ b/backend/app/Services/Infrastructure/Webhook/WebhookDispatchService.php @@ -0,0 +1,199 @@ +onQueue($this->config->get('queue.webhook_queue_name')); + } + + public function queueProductWebhook(WebhookEventType $eventType, int $productId): void + { + DispatchProductWebhookJob::dispatch( + productId: $productId, + eventType: $eventType, + )->onQueue($this->config->get('queue.webhook_queue_name')); + } + + public function queueCheckInWebhook(WebhookEventType $eventType, int $attendeeCheckInId): void + { + DispatchCheckInWebhookJob::dispatch( + attendeeCheckInId: $attendeeCheckInId, + eventType: $eventType, + )->onQueue($this->config->get('queue.webhook_queue_name')); + } + + public function queueAttendeeWebhook(WebhookEventType $eventType, int $attendeeId): void + { + DispatchAttendeeWebhookJob::dispatch( + attendeeId: $attendeeId, + eventType: $eventType, + )->onQueue($this->config->get('queue.webhook_queue_name')); + } + + public function dispatchAttendeeWebhook(WebhookEventType $eventType, int $attendeeId): void + { + $attendee = $this->attendeeRepository + ->loadRelation(new Relationship( + domainObject: QuestionAndAnswerViewDomainObject::class, + name: 'question_and_answer_views', + )) + ->findById($attendeeId); + + $this->dispatchWebhook( + eventType: $eventType, + payload: new AttendeeResource($attendee), + eventId: $attendee->getEventId(), + ); + } + + public function dispatchCheckInWebhook(WebhookEventType $eventType, int $attendeeCheckInId): void + { + $attendeeCheckIn = $this->attendeeCheckInRepository + ->loadRelation(new Relationship( + domainObject: AttendeeDomainObject::class, + name: 'attendee', + )) + ->includeDeleted() + ->findById($attendeeCheckInId); + + $this->dispatchWebhook( + eventType: $eventType, + payload: new AttendeeCheckInResource($attendeeCheckIn), + eventId: $attendeeCheckIn->getEventId(), + ); + } + + public function dispatchProductWebhook(WebhookEventType $eventType, int $productId): void + { + $product = $this->productRepository + ->loadRelation(ProductPriceDomainObject::class) + ->loadRelation(TaxAndFeesDomainObject::class) + ->includeDeleted() + ->findById($productId); + + $this->dispatchWebhook( + eventType: $eventType, + payload: new ProductResource($product), + eventId: $product->getEventId(), + ); + } + + public function dispatchOrderWebhook(WebhookEventType $eventType, int $orderId): void + { + $order = $this->orderRepository + ->loadRelation(OrderItemDomainObject::class) + ->loadRelation(new Relationship( + domainObject: AttendeeDomainObject::class, + nested: [ + new Relationship( + domainObject: QuestionAndAnswerViewDomainObject::class, + name: 'question_and_answer_views', + ), + ], + name: 'attendees') + ) + ->loadRelation(QuestionAndAnswerViewDomainObject::class) + ->findById($orderId); + + if ($eventType === WebhookEventType::ORDER_CREATED) { + /** @var AttendeeDomainObject $attendee */ + foreach ($order->getAttendees() as $attendee) { + $this->queueAttendeeWebhook( + eventType: WebhookEventType::ATTENDEE_CREATED, + attendeeId: $attendee->getId(), + ); + } + } + + if ($eventType === WebhookEventType::ORDER_CANCELLED) { + /** @var AttendeeDomainObject $attendee */ + foreach ($order->getAttendees() as $attendee) { + $this->queueAttendeeWebhook( + eventType: WebhookEventType::ATTENDEE_CANCELLED, + attendeeId: $attendee->getId(), + ); + } + } + + $this->dispatchWebhook( + $eventType, + new OrderResource($order), + $order->getEventId(), + ); + } + + private function dispatchWebhook(WebhookEventType $eventType, JsonResource $payload, int $eventId): void + { + /** @var Collection $webhooks */ + $webhooks = $this->webhookRepository->findWhere([ + 'event_id' => $eventId, + 'status' => WebhookStatus::ENABLED->name, + ]) + ->filter(fn(WebhookDomainObject $webhook) => in_array($eventType->value, $webhook->getEventTypes(), true)); + + foreach ($webhooks as $webhook) { + $this->logger->info("Dispatching webhook for event ID: $eventId and webhook ID: {$webhook->getId()}"); + + WebhookCall::create() + ->url($webhook->getUrl()) + ->payload([ + 'event_type' => $eventType->value, + 'event_sent_at' => now()->toIso8601String(), + 'payload' => $payload->resolve() + ]) + ->useSecret($webhook->getSecret()) + ->meta([ + 'webhook_id' => $webhook->getId(), + 'event_id' => $eventId, + 'event_type' => $eventType->name, + ]) + ->dispatchSync(); + } + } +} diff --git a/backend/app/Services/Infrastructure/Webhook/WebhookResponseHandlerService.php b/backend/app/Services/Infrastructure/Webhook/WebhookResponseHandlerService.php new file mode 100644 index 0000000000..1f1bd332c1 --- /dev/null +++ b/backend/app/Services/Infrastructure/Webhook/WebhookResponseHandlerService.php @@ -0,0 +1,66 @@ +databaseManager->transaction(function () use ($payload, $eventType, $eventId, $webhookId, $response) { + $webhook = $this->webhookRepository->findFirstWhere([ + 'id' => $webhookId, + 'event_id' => $eventId, + ]); + + if (!$webhook) { + $this->logger->error("Webhook not found for ID: $webhookId and event ID: $eventId"); + return; + } + + $status = $response?->getStatusCode() ?? 0; + $responseBody = $response ? substr($response->getBody()->getContents(), 0, 1000) : null; + + $this->webhookRepository->updateWhere( + attributes: [ + 'last_response_code' => $status, + 'last_response_body' => $responseBody, + 'last_triggered_at' => now(), + ], + where: [ + 'id' => $webhookId, + 'event_id' => $eventId, + ]); + + $this->webhookLogRepository->create([ + 'webhook_id' => $webhook->getId(), + 'payload' => json_encode($payload, JSON_THROW_ON_ERROR), + 'response_code' => $status, + 'event_type' => $eventType, + 'response_body' => $responseBody, + ]); + + $this->webhookLogRepository->deleteOldLogs($webhookId); + }); + } +} diff --git a/backend/app/Validators/CompleteOrderValidator.php b/backend/app/Validators/CompleteOrderValidator.php index 6b8ffc5b9a..948a1b1b49 100644 --- a/backend/app/Validators/CompleteOrderValidator.php +++ b/backend/app/Validators/CompleteOrderValidator.php @@ -5,24 +5,27 @@ namespace HiEvents\Validators; use HiEvents\DomainObjects\Enums\QuestionBelongsTo; +use HiEvents\DomainObjects\EventSettingDomainObject; +use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract; use HiEvents\DomainObjects\Generated\QuestionDomainObjectAbstract; -use HiEvents\DomainObjects\Generated\TicketDomainObjectAbstract; +use HiEvents\DomainObjects\ProductDomainObject; +use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\DomainObjects\QuestionDomainObject; -use HiEvents\DomainObjects\TicketDomainObject; -use HiEvents\DomainObjects\TicketPriceDomainObject; use HiEvents\Repository\Eloquent\Value\Relationship; +use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface; +use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use HiEvents\Repository\Interfaces\QuestionRepositoryInterface; -use HiEvents\Repository\Interfaces\TicketRepositoryInterface; -use HiEvents\Validators\Rules\AttendeeQuestionRule; use HiEvents\Validators\Rules\OrderQuestionRule; +use HiEvents\Validators\Rules\ProductQuestionRule; use Illuminate\Routing\Route; class CompleteOrderValidator extends BaseValidator { public function __construct( - private readonly QuestionRepositoryInterface $questionRepository, - private readonly TicketRepositoryInterface $ticketRepository, - private readonly Route $route + private readonly QuestionRepositoryInterface $questionRepository, + private readonly ProductRepositoryInterface $productRepository, + private readonly EventSettingsRepositoryInterface $eventSettingsRepository, + private readonly Route $route ) { } @@ -31,43 +34,50 @@ public function rules(): array { $questions = $this->questionRepository ->loadRelation( - new Relationship(TicketDomainObject::class, [ - new Relationship(TicketPriceDomainObject::class) + new Relationship(ProductDomainObject::class, [ + new Relationship(ProductPriceDomainObject::class) ]) ) ->findWhere( [QuestionDomainObjectAbstract::EVENT_ID => $this->route->parameter('event_id')] ); + $orderQuestions = $questions->filter( fn(QuestionDomainObject $question) => $question->getBelongsTo() === QuestionBelongsTo::ORDER->name ); - $ticketQuestions = $questions->filter( - fn(QuestionDomainObject $question) => $question->getBelongsTo() === QuestionBelongsTo::TICKET->name + + $productQuestions = $questions->filter( + fn(QuestionDomainObject $question) => $question->getBelongsTo() === QuestionBelongsTo::PRODUCT->name ); - $tickets = $this->ticketRepository - ->loadRelation(TicketPriceDomainObject::class) + $products = $this->productRepository + ->loadRelation(ProductPriceDomainObject::class) ->findWhere( - [TicketDomainObjectAbstract::EVENT_ID => $this->route->parameter('event_id')] + [ProductDomainObjectAbstract::EVENT_ID => $this->route->parameter('event_id')] ); + /** @var EventSettingDomainObject $eventSettings */ + $eventSettings = $this->eventSettingsRepository->findFirstWhere([ + 'event_id' => $this->route->parameter('event_id'), + ]); + + $addressRules = $eventSettings->getRequireBillingAddress() ? [ + 'order.address' => 'array', + 'order.address.address_line_1' => 'required|string|max:255', + 'order.address.address_line_2' => 'nullable|string|max:255', + 'order.address.city' => 'required|string|max:85', + 'order.address.state_or_region' => 'nullable|string|max:85', + 'order.address.zip_or_postal_code' => 'nullable|string|max:85', + 'order.address.country' => 'required|string|max:2', + ] : []; + return [ 'order.first_name' => ['required', 'string', 'max:40'], 'order.last_name' => ['required', 'string', 'max:40'], - 'order.questions' => new OrderQuestionRule($orderQuestions, $tickets), + 'order.questions' => new OrderQuestionRule($orderQuestions, $products), 'order.email' => 'required|email', - 'attendees.*.first_name' => ['required', 'string', 'max:40'], - 'attendees.*.last_name' => ['required', 'string', 'max:40'], - 'attendees.*.email' => ['required', 'email'], - 'attendees' => new AttendeeQuestionRule($ticketQuestions, $tickets), - - // Address validation is intentionally not strict, as we want to support all countries - 'order.address.address_line_1' => ['string', 'max:255'], - 'order.address.address_line_2' => ['string', 'max:255', 'nullable'], - 'order.address.city' => ['string', 'max:85'], - 'order.address.state_or_region' => ['string', 'max:85'], - 'order.address.zip_or_postal_code' => ['string', 'max:85'], - 'order.address.country' => ['string', 'max:2'], + 'products' => new ProductQuestionRule($productQuestions, $products), + ...$addressRules ]; } @@ -77,9 +87,10 @@ public function messages(): array 'order.first_name' => __('First name is required'), 'order.last_name' => __('Last name is required'), 'order.email' => __('A valid email is required'), - 'attendees.*.first_name' => __('First name is required'), - 'attendees.*.last_name' => __('Last name is required'), - 'attendees.*.email' => __('A valid email is required'), + 'order.address.address_line_1.required' => __('Address line 1 is required'), + 'order.address.city.required' => __('City is required'), + 'order.address.zip_or_postal_code.required' => __('Zip or postal code is required'), + 'order.address.country.required' => __('Country is required'), ]; } } diff --git a/backend/app/Validators/Rules/BaseQuestionRule.php b/backend/app/Validators/Rules/BaseQuestionRule.php index 7571bf1334..f30ce6e0f1 100644 --- a/backend/app/Validators/Rules/BaseQuestionRule.php +++ b/backend/app/Validators/Rules/BaseQuestionRule.php @@ -5,8 +5,8 @@ use Closure; use HiEvents\DomainObjects\Enums\QuestionTypeEnum; use HiEvents\DomainObjects\QuestionDomainObject; -use HiEvents\DomainObjects\TicketDomainObject; -use HiEvents\DomainObjects\TicketPriceDomainObject; +use HiEvents\DomainObjects\ProductDomainObject; +use HiEvents\DomainObjects\ProductPriceDomainObject; use Illuminate\Contracts\Validation\DataAwareRule; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidatorAwareRule; @@ -34,7 +34,7 @@ abstract class BaseQuestionRule implements ValidationRule, DataAwareRule, Valida protected Collection $questions; - private Collection $tickets; + private Collection $products; protected Validator $validator; @@ -44,20 +44,20 @@ abstract protected function validateRequiredQuestionArePresent(Collection $data) abstract protected function validateQuestions(mixed $data): array; - public function __construct(Collection $questions, Collection $tickets) + public function __construct(Collection $questions, Collection $products) { $this->questions = $questions; - $this->tickets = $tickets; + $this->products = $products; } public function validate(string $attribute, mixed $value, Closure $fail): void { $this->validateRequiredQuestionArePresent(collect($value)); - $validationMessages = $this->validateQuestions($value); + $questionValidationMessages = $this->validateQuestions($value); - if ($validationMessages) { - $this->validator->messages()->merge($validationMessages); + if ($questionValidationMessages) { + $this->validator->messages()->merge($questionValidationMessages); } } @@ -73,21 +73,21 @@ public function setData(array $data): void $this->data = $data; } - protected function getTicketIdFromTicketPriceId(int $ticketPriceId): int + protected function getProductIdFromProductPriceId(int $productPriceId): int { - $ticketPrices = new Collection(); - $this->tickets->each(fn(TicketDomainObject $ticket) => $ticketPrices->push(...$ticket->getTicketPrices())); + $productPrices = new Collection(); + $this->products->each(fn(ProductDomainObject $product) => $productPrices->push(...$product->getProductPrices())); - /** @var TicketPriceDomainObject $ticketPrice */ - $ticketPrice = $ticketPrices - ->first(fn(TicketPriceDomainObject $ticketPrice) => $ticketPrice->getId() === $ticketPriceId); + /** @var ProductPriceDomainObject $productPrice */ + $productPrice = $productPrices + ->first(fn(ProductPriceDomainObject $productPrice) => $productPrice->getId() === $productPriceId); - return $ticketPrice->getTicketId(); + return $productPrice->getProductId(); } protected function isAnswerValid(QuestionDomainObject $questionDomainObject, mixed $response): bool { - if (!$questionDomainObject->isMultipleChoice()) { + if (!$questionDomainObject->isPreDefinedChoice()) { return true; } @@ -96,7 +96,7 @@ protected function isAnswerValid(QuestionDomainObject $questionDomainObject, mix } if (is_string($response['answer'])) { - return in_array($response, $questionDomainObject->getOptions(), true); + return in_array($response['answer'], $questionDomainObject->getOptions(), true); } return array_diff((array)$response['answer'], $questionDomainObject->getOptions()) === []; @@ -160,4 +160,9 @@ protected function validateResponseLength( return $validationMessages; } + + protected function getProductDomainObject(int $id): ?ProductDomainObject + { + return $this->products->filter(fn($product) => $product->getId() === $id)?->first(); + } } diff --git a/backend/app/Validators/Rules/AttendeeQuestionRule.php b/backend/app/Validators/Rules/ProductQuestionRule.php similarity index 50% rename from backend/app/Validators/Rules/AttendeeQuestionRule.php rename to backend/app/Validators/Rules/ProductQuestionRule.php index 87640c9e59..534dd92ef8 100644 --- a/backend/app/Validators/Rules/AttendeeQuestionRule.php +++ b/backend/app/Validators/Rules/ProductQuestionRule.php @@ -2,26 +2,28 @@ namespace HiEvents\Validators\Rules; +use HiEvents\DomainObjects\Enums\ProductType; use HiEvents\DomainObjects\QuestionDomainObject; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; -class AttendeeQuestionRule extends BaseQuestionRule +class ProductQuestionRule extends BaseQuestionRule { /** * @throws ValidationException */ - protected function validateRequiredQuestionArePresent(Collection $orderAttendees): void + protected function validateRequiredQuestionArePresent(Collection $orderProducts): void { - foreach ($orderAttendees as $attendee) { - $ticketId = $this->getTicketIdFromTicketPriceId($attendee['ticket_price_id']); - $questions = $attendee['questions'] ?? []; + foreach ($orderProducts as $productData) { + $productId = $this->getProductIdFromProductPriceId($productData['product_price_id']); + $questions = $productData['questions'] ?? []; $requiredQuestionIds = $this->questions - ->filter(function (QuestionDomainObject $question) use ($ticketId) { + ->filter(function (QuestionDomainObject $question) use ($productId) { return $question->getRequired() && !$question->getIsHidden() - && $question->getTickets()?->map(fn($ticket) => $ticket->getId())->contains($ticketId); + && $question->getProducts()?->map(fn($product) => $product->getId())->contains($productId); }) ->map(fn(QuestionDomainObject $question) => $question->getId()); @@ -33,15 +35,29 @@ protected function validateRequiredQuestionArePresent(Collection $orderAttendees } } - protected function validateQuestions(mixed $attendees): array + protected function validateQuestions(mixed $products): array { $validationMessages = []; - foreach ($attendees as $attendeeIndex => $attendee) { - $questions = $attendee['questions'] ?? []; + foreach ($products as $productIndex => $productRequestData) { + $productDomainObject = $this->getProductDomainObject($productRequestData['product_id']); + + if (!$productDomainObject) { + $validationMessages['products.' . $productIndex][] = __('This product is outdated. Please reload the page.'); + continue; + } + + if ($productDomainObject->getProductType() === ProductType::TICKET->name) { + $validationMessages = [ + ...$validationMessages, + ...$this->validateBasicTicketFields($productRequestData, $productIndex), + ]; + } + + $questions = $productRequestData['questions'] ?? []; foreach ($questions as $questionIndex => $question) { $questionDomainObject = $this->getQuestionDomainObject($question['question_id'] ?? null); - $key = 'attendees.' . $attendeeIndex . '.questions.' . $questionIndex . '.response'; + $key = 'products.' . $productIndex . '.questions.' . $questionIndex . '.response'; $response = empty($question['response']) ? null : $question['response']; if (!$questionDomainObject) { @@ -67,4 +83,25 @@ protected function validateQuestions(mixed $attendees): array return $validationMessages; } + + private function validateBasicTicketFields(mixed $productRequestData, int|string $productIndex): array + { + $validationMessages = []; + + $validator = Validator::make($productRequestData, [ + 'first_name' => ['required', 'string', 'min:1', 'max:100'], + 'last_name' => ['required', 'string', 'min:1', 'max:100'], + 'email' => ['required', 'string', 'email', 'max:100'], + ]); + + if ($validator->fails()) { + foreach ($validator->errors()->messages() as $field => $messages) { + foreach ($messages as $message) { + $validationMessages["products.$productIndex.$field"][] = $message; + } + } + } + + return $validationMessages; + } } diff --git a/backend/app/Validators/Rules/RulesHelper.php b/backend/app/Validators/Rules/RulesHelper.php index 20826cfe69..a68729c2de 100644 --- a/backend/app/Validators/Rules/RulesHelper.php +++ b/backend/app/Validators/Rules/RulesHelper.php @@ -16,4 +16,5 @@ class RulesHelper public const REQUIRED_EMAIL = ['email' , 'required', 'max:100']; + public const OPTIONAL_TEXT_MEDIUM_LENGTH = ['string', 'max:2000', 'nullable']; } diff --git a/backend/bootstrap/cache/packages.php b/backend/bootstrap/cache/packages.php deleted file mode 100755 index 206aa19710..0000000000 --- a/backend/bootstrap/cache/packages.php +++ /dev/null @@ -1,85 +0,0 @@ - - array ( - 'providers' => - array ( - 0 => 'Druc\\Langscanner\\LangscannerServiceProvider', - ), - ), - 'laravel/sail' => - array ( - 'providers' => - array ( - 0 => 'Laravel\\Sail\\SailServiceProvider', - ), - ), - 'laravel/sanctum' => - array ( - 'providers' => - array ( - 0 => 'Laravel\\Sanctum\\SanctumServiceProvider', - ), - ), - 'laravel/tinker' => - array ( - 'providers' => - array ( - 0 => 'Laravel\\Tinker\\TinkerServiceProvider', - ), - ), - 'maatwebsite/excel' => - array ( - 'providers' => - array ( - 0 => 'Maatwebsite\\Excel\\ExcelServiceProvider', - ), - 'aliases' => - array ( - 'Excel' => 'Maatwebsite\\Excel\\Facades\\Excel', - ), - ), - 'nesbot/carbon' => - array ( - 'providers' => - array ( - 0 => 'Carbon\\Laravel\\ServiceProvider', - ), - ), - 'nunomaduro/collision' => - array ( - 'providers' => - array ( - 0 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider', - ), - ), - 'nunomaduro/termwind' => - array ( - 'providers' => - array ( - 0 => 'Termwind\\Laravel\\TermwindServiceProvider', - ), - ), - 'php-open-source-saver/jwt-auth' => - array ( - 'aliases' => - array ( - 'JWTAuth' => 'PHPOpenSourceSaver\\JWTAuth\\Facades\\JWTAuth', - 'JWTFactory' => 'PHPOpenSourceSaver\\JWTAuth\\Facades\\JWTFactory', - ), - 'providers' => - array ( - 0 => 'PHPOpenSourceSaver\\JWTAuth\\Providers\\LaravelServiceProvider', - ), - ), - 'spatie/laravel-ignition' => - array ( - 'providers' => - array ( - 0 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider', - ), - 'aliases' => - array ( - 'Flare' => 'Spatie\\LaravelIgnition\\Facades\\Flare', - ), - ), -); \ No newline at end of file diff --git a/backend/bootstrap/cache/services.php b/backend/bootstrap/cache/services.php deleted file mode 100755 index 80afc4091b..0000000000 --- a/backend/bootstrap/cache/services.php +++ /dev/null @@ -1,265 +0,0 @@ - - array ( - 0 => 'Illuminate\\Auth\\AuthServiceProvider', - 1 => 'Illuminate\\Broadcasting\\BroadcastServiceProvider', - 2 => 'Illuminate\\Bus\\BusServiceProvider', - 3 => 'Illuminate\\Cache\\CacheServiceProvider', - 4 => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 5 => 'Illuminate\\Cookie\\CookieServiceProvider', - 6 => 'Illuminate\\Database\\DatabaseServiceProvider', - 7 => 'Illuminate\\Encryption\\EncryptionServiceProvider', - 8 => 'Illuminate\\Filesystem\\FilesystemServiceProvider', - 9 => 'Illuminate\\Foundation\\Providers\\FoundationServiceProvider', - 10 => 'Illuminate\\Hashing\\HashServiceProvider', - 11 => 'Illuminate\\Mail\\MailServiceProvider', - 12 => 'Illuminate\\Notifications\\NotificationServiceProvider', - 13 => 'Illuminate\\Pagination\\PaginationServiceProvider', - 14 => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider', - 15 => 'Illuminate\\Pipeline\\PipelineServiceProvider', - 16 => 'Illuminate\\Queue\\QueueServiceProvider', - 17 => 'Illuminate\\Redis\\RedisServiceProvider', - 18 => 'Illuminate\\Session\\SessionServiceProvider', - 19 => 'Illuminate\\Translation\\TranslationServiceProvider', - 20 => 'Illuminate\\Validation\\ValidationServiceProvider', - 21 => 'Illuminate\\View\\ViewServiceProvider', - 22 => 'Druc\\Langscanner\\LangscannerServiceProvider', - 23 => 'Laravel\\Sail\\SailServiceProvider', - 24 => 'Laravel\\Sanctum\\SanctumServiceProvider', - 25 => 'Laravel\\Tinker\\TinkerServiceProvider', - 26 => 'Maatwebsite\\Excel\\ExcelServiceProvider', - 27 => 'Carbon\\Laravel\\ServiceProvider', - 28 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider', - 29 => 'Termwind\\Laravel\\TermwindServiceProvider', - 30 => 'PHPOpenSourceSaver\\JWTAuth\\Providers\\LaravelServiceProvider', - 31 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider', - 32 => 'HiEvents\\Providers\\AppServiceProvider', - 33 => 'HiEvents\\Providers\\AuthServiceProvider', - 34 => 'HiEvents\\Providers\\EventServiceProvider', - 35 => 'HiEvents\\Providers\\RouteServiceProvider', - 36 => 'HiEvents\\Providers\\RepositoryServiceProvider', - ), - 'eager' => - array ( - 0 => 'Illuminate\\Auth\\AuthServiceProvider', - 1 => 'Illuminate\\Cookie\\CookieServiceProvider', - 2 => 'Illuminate\\Database\\DatabaseServiceProvider', - 3 => 'Illuminate\\Encryption\\EncryptionServiceProvider', - 4 => 'Illuminate\\Filesystem\\FilesystemServiceProvider', - 5 => 'Illuminate\\Foundation\\Providers\\FoundationServiceProvider', - 6 => 'Illuminate\\Notifications\\NotificationServiceProvider', - 7 => 'Illuminate\\Pagination\\PaginationServiceProvider', - 8 => 'Illuminate\\Session\\SessionServiceProvider', - 9 => 'Illuminate\\View\\ViewServiceProvider', - 10 => 'Druc\\Langscanner\\LangscannerServiceProvider', - 11 => 'Laravel\\Sanctum\\SanctumServiceProvider', - 12 => 'Maatwebsite\\Excel\\ExcelServiceProvider', - 13 => 'Carbon\\Laravel\\ServiceProvider', - 14 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider', - 15 => 'Termwind\\Laravel\\TermwindServiceProvider', - 16 => 'PHPOpenSourceSaver\\JWTAuth\\Providers\\LaravelServiceProvider', - 17 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider', - 18 => 'HiEvents\\Providers\\AppServiceProvider', - 19 => 'HiEvents\\Providers\\AuthServiceProvider', - 20 => 'HiEvents\\Providers\\EventServiceProvider', - 21 => 'HiEvents\\Providers\\RouteServiceProvider', - 22 => 'HiEvents\\Providers\\RepositoryServiceProvider', - ), - 'deferred' => - array ( - 'Illuminate\\Broadcasting\\BroadcastManager' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider', - 'Illuminate\\Contracts\\Broadcasting\\Factory' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider', - 'Illuminate\\Contracts\\Broadcasting\\Broadcaster' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider', - 'Illuminate\\Bus\\Dispatcher' => 'Illuminate\\Bus\\BusServiceProvider', - 'Illuminate\\Contracts\\Bus\\Dispatcher' => 'Illuminate\\Bus\\BusServiceProvider', - 'Illuminate\\Contracts\\Bus\\QueueingDispatcher' => 'Illuminate\\Bus\\BusServiceProvider', - 'Illuminate\\Bus\\BatchRepository' => 'Illuminate\\Bus\\BusServiceProvider', - 'Illuminate\\Bus\\DatabaseBatchRepository' => 'Illuminate\\Bus\\BusServiceProvider', - 'cache' => 'Illuminate\\Cache\\CacheServiceProvider', - 'cache.store' => 'Illuminate\\Cache\\CacheServiceProvider', - 'cache.psr6' => 'Illuminate\\Cache\\CacheServiceProvider', - 'memcached.connector' => 'Illuminate\\Cache\\CacheServiceProvider', - 'Illuminate\\Cache\\RateLimiter' => 'Illuminate\\Cache\\CacheServiceProvider', - 'Illuminate\\Foundation\\Console\\AboutCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Cache\\Console\\ClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Cache\\Console\\ForgetCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ClearCompiledCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Auth\\Console\\ClearResetsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ConfigCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ConfigClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ConfigShowCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\DbCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\PruneCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\ShowCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\TableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\WipeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\DownCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\EnvironmentCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\EnvironmentDecryptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\EnvironmentEncryptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\EventCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\EventClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\EventListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\KeyGenerateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\OptimizeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\OptimizeClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\PackageDiscoverCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Cache\\Console\\PruneStaleTagsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\ClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\ListFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\FlushFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\ForgetFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\ListenCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\PruneBatchesCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\PruneFailedJobsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\RestartCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\RetryCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\RetryBatchCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\WorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\RouteCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\RouteClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\RouteListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\DumpCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\Seeds\\SeedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Console\\Scheduling\\ScheduleFinishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Console\\Scheduling\\ScheduleListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Console\\Scheduling\\ScheduleRunCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Console\\Scheduling\\ScheduleClearCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Console\\Scheduling\\ScheduleTestCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Console\\Scheduling\\ScheduleWorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Console\\Scheduling\\ScheduleInterruptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\ShowModelCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\StorageLinkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\StorageUnlinkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\UpCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ViewCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ViewClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ApiInstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\BroadcastingInstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Cache\\Console\\CacheTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\CastMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ChannelListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ChannelMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ClassMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ComponentMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ConfigPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ConsoleMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Routing\\Console\\ControllerMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\DocsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\EnumMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\EventGenerateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\EventMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ExceptionMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\Factories\\FactoryMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\InterfaceMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\JobMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\LangPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ListenerMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\MailMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Routing\\Console\\MiddlewareMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ModelMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\NotificationMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Notifications\\Console\\NotificationTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ObserverMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\PolicyMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ProviderMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\FailedTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\TableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\BatchesTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\RequestMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ResourceMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\RuleMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ScopeMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\Seeds\\SeederMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Session\\Console\\SessionTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ServeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\StubPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\TestMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\TraitMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\VendorPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ViewMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'migrator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'migration.repository' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'migration.creator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\Migrations\\MigrateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\Migrations\\FreshCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\Migrations\\InstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\Migrations\\RefreshCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\Migrations\\ResetCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\Migrations\\RollbackCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\Migrations\\StatusCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\Migrations\\MigrateMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'composer' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'hash' => 'Illuminate\\Hashing\\HashServiceProvider', - 'hash.driver' => 'Illuminate\\Hashing\\HashServiceProvider', - 'mail.manager' => 'Illuminate\\Mail\\MailServiceProvider', - 'mailer' => 'Illuminate\\Mail\\MailServiceProvider', - 'Illuminate\\Mail\\Markdown' => 'Illuminate\\Mail\\MailServiceProvider', - 'auth.password' => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider', - 'auth.password.broker' => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider', - 'Illuminate\\Contracts\\Pipeline\\Hub' => 'Illuminate\\Pipeline\\PipelineServiceProvider', - 'pipeline' => 'Illuminate\\Pipeline\\PipelineServiceProvider', - 'queue' => 'Illuminate\\Queue\\QueueServiceProvider', - 'queue.connection' => 'Illuminate\\Queue\\QueueServiceProvider', - 'queue.failer' => 'Illuminate\\Queue\\QueueServiceProvider', - 'queue.listener' => 'Illuminate\\Queue\\QueueServiceProvider', - 'queue.worker' => 'Illuminate\\Queue\\QueueServiceProvider', - 'redis' => 'Illuminate\\Redis\\RedisServiceProvider', - 'redis.connection' => 'Illuminate\\Redis\\RedisServiceProvider', - 'translator' => 'Illuminate\\Translation\\TranslationServiceProvider', - 'translation.loader' => 'Illuminate\\Translation\\TranslationServiceProvider', - 'validator' => 'Illuminate\\Validation\\ValidationServiceProvider', - 'validation.presence' => 'Illuminate\\Validation\\ValidationServiceProvider', - 'Illuminate\\Contracts\\Validation\\UncompromisedVerifier' => 'Illuminate\\Validation\\ValidationServiceProvider', - 'Laravel\\Sail\\Console\\InstallCommand' => 'Laravel\\Sail\\SailServiceProvider', - 'Laravel\\Sail\\Console\\PublishCommand' => 'Laravel\\Sail\\SailServiceProvider', - 'command.tinker' => 'Laravel\\Tinker\\TinkerServiceProvider', - ), - 'when' => - array ( - 'Illuminate\\Broadcasting\\BroadcastServiceProvider' => - array ( - ), - 'Illuminate\\Bus\\BusServiceProvider' => - array ( - ), - 'Illuminate\\Cache\\CacheServiceProvider' => - array ( - ), - 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider' => - array ( - ), - 'Illuminate\\Hashing\\HashServiceProvider' => - array ( - ), - 'Illuminate\\Mail\\MailServiceProvider' => - array ( - ), - 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider' => - array ( - ), - 'Illuminate\\Pipeline\\PipelineServiceProvider' => - array ( - ), - 'Illuminate\\Queue\\QueueServiceProvider' => - array ( - ), - 'Illuminate\\Redis\\RedisServiceProvider' => - array ( - ), - 'Illuminate\\Translation\\TranslationServiceProvider' => - array ( - ), - 'Illuminate\\Validation\\ValidationServiceProvider' => - array ( - ), - 'Laravel\\Sail\\SailServiceProvider' => - array ( - ), - 'Laravel\\Tinker\\TinkerServiceProvider' => - array ( - ), - ), -); \ No newline at end of file diff --git a/backend/composer.json b/backend/composer.json index 64a37c8ca3..53d578b551 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -8,6 +8,7 @@ "require": { "php": "^8.2", "ext-intl": "*", + "barryvdh/laravel-dompdf": "^3.0", "brick/money": "^0.8.0", "doctrine/dbal": "^3.6", "druc/laravel-langscanner": "^2.2", @@ -16,11 +17,14 @@ "laravel/framework": "^11.0", "laravel/sanctum": "^4.0", "laravel/tinker": "^2.8", + "laravel/vapor-core": "^2.37", "league/flysystem-aws-s3-v3": "^3.0", "maatwebsite/excel": "^3.1", "nette/php-generator": "^4.0", "php-open-source-saver/jwt-auth": "^2.1", "spatie/icalendar-generator": "^2.8", + "spatie/laravel-data": "^4.11", + "spatie/laravel-webhook-server": "^3.8", "stripe/stripe-php": "^10.15" }, "require-dev": { diff --git a/backend/composer.lock b/backend/composer.lock index ea65c93a12..122441a019 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,8 +4,816 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "da22882240b65224977eabdf78856ddd", + "content-hash": "029aa5c375f82fe7cf955fcc80e5222b", "packages": [ + { + "name": "amphp/amp", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/amp.git", + "reference": "138801fb68cfc9c329da8a7b39d01ce7291ee4b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/amp/zipball/138801fb68cfc9c329da8a7b39d01ce7291ee4b0", + "reference": "138801fb68cfc9c329da8a7b39d01ce7291ee4b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Future/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A non-blocking concurrency framework for PHP applications.", + "homepage": "https://amphp.org/amp", + "keywords": [ + "async", + "asynchronous", + "awaitable", + "concurrency", + "event", + "event-loop", + "future", + "non-blocking", + "promise" + ], + "support": { + "issues": "https://github.com/amphp/amp/issues", + "source": "https://github.com/amphp/amp/tree/v3.0.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-05-10T21:37:46+00:00" + }, + { + "name": "amphp/byte-stream", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/byte-stream.git", + "reference": "daa00f2efdbd71565bf64ffefa89e37542addf93" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/byte-stream/zipball/daa00f2efdbd71565bf64ffefa89e37542addf93", + "reference": "daa00f2efdbd71565bf64ffefa89e37542addf93", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/parser": "^1.1", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2.3" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.22.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\ByteStream\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A stream abstraction to make working with non-blocking I/O simple.", + "homepage": "https://amphp.org/byte-stream", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "non-blocking", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/byte-stream/issues", + "source": "https://github.com/amphp/byte-stream/tree/v2.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-02-17T04:49:38+00:00" + }, + { + "name": "amphp/cache", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/cache.git", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Cache\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A fiber-aware cache API based on Amp and Revolt.", + "homepage": "https://amphp.org/cache", + "support": { + "issues": "https://github.com/amphp/cache/issues", + "source": "https://github.com/amphp/cache/tree/v2.0.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:38:06+00:00" + }, + { + "name": "amphp/dns", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/dns.git", + "reference": "166c43737cef1b77782c648a9d9ed11ee0c9859f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/dns/zipball/166c43737cef1b77782c648a9d9ed11ee0c9859f", + "reference": "166c43737cef1b77782c648a9d9ed11ee0c9859f", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/parser": "^1", + "amphp/process": "^2", + "daverandom/libdns": "^2.0.2", + "ext-filter": "*", + "ext-json": "*", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Dns\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Wright", + "email": "addr@daverandom.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "Async DNS resolution for Amp.", + "homepage": "https://github.com/amphp/dns", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "dns", + "resolve" + ], + "support": { + "issues": "https://github.com/amphp/dns/issues", + "source": "https://github.com/amphp/dns/tree/v2.3.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-12-21T01:15:34+00:00" + }, + { + "name": "amphp/parallel", + "version": "v2.3.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/parallel.git", + "reference": "5113111de02796a782f5d90767455e7391cca190" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/parallel/zipball/5113111de02796a782f5d90767455e7391cca190", + "reference": "5113111de02796a782f5d90767455e7391cca190", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/parser": "^1", + "amphp/pipeline": "^1", + "amphp/process": "^2", + "amphp/serialization": "^1", + "amphp/socket": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.18" + }, + "type": "library", + "autoload": { + "files": [ + "src/Context/functions.php", + "src/Context/Internal/functions.php", + "src/Ipc/functions.php", + "src/Worker/functions.php" + ], + "psr-4": { + "Amp\\Parallel\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" + } + ], + "description": "Parallel processing component for Amp.", + "homepage": "https://github.com/amphp/parallel", + "keywords": [ + "async", + "asynchronous", + "concurrent", + "multi-processing", + "multi-threading" + ], + "support": { + "issues": "https://github.com/amphp/parallel/issues", + "source": "https://github.com/amphp/parallel/tree/v2.3.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-12-21T01:56:09+00:00" + }, + { + "name": "amphp/parser", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/parser.git", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Parser\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A generator parser to make streaming parsers simple.", + "homepage": "https://github.com/amphp/parser", + "keywords": [ + "async", + "non-blocking", + "parser", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/parser/issues", + "source": "https://github.com/amphp/parser/tree/v1.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:16:53+00:00" + }, + { + "name": "amphp/pipeline", + "version": "v1.2.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/pipeline.git", + "reference": "66c095673aa5b6e689e63b52d19e577459129ab3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/66c095673aa5b6e689e63b52d19e577459129ab3", + "reference": "66c095673aa5b6e689e63b52d19e577459129ab3", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.18" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Pipeline\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Asynchronous iterators and operators.", + "homepage": "https://amphp.org/pipeline", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "iterator", + "non-blocking" + ], + "support": { + "issues": "https://github.com/amphp/pipeline/issues", + "source": "https://github.com/amphp/pipeline/tree/v1.2.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-07-04T00:56:47+00:00" + }, + { + "name": "amphp/process", + "version": "v2.0.3", + "source": { + "type": "git", + "url": "https://github.com/amphp/process.git", + "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/process/zipball/52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", + "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Process\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A fiber-aware process manager based on Amp and Revolt.", + "homepage": "https://amphp.org/process", + "support": { + "issues": "https://github.com/amphp/process/issues", + "source": "https://github.com/amphp/process/tree/v2.0.3" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:13:44+00:00" + }, + { + "name": "amphp/serialization", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/serialization.git", + "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/serialization/zipball/693e77b2fb0b266c3c7d622317f881de44ae94a1", + "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "dev-master", + "phpunit/phpunit": "^9 || ^8 || ^7" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Serialization\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Serialization tools for IPC and data storage in PHP.", + "homepage": "https://github.com/amphp/serialization", + "keywords": [ + "async", + "asynchronous", + "serialization", + "serialize" + ], + "support": { + "issues": "https://github.com/amphp/serialization/issues", + "source": "https://github.com/amphp/serialization/tree/master" + }, + "time": "2020-03-25T21:39:07+00:00" + }, + { + "name": "amphp/socket", + "version": "v2.3.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/socket.git", + "reference": "58e0422221825b79681b72c50c47a930be7bf1e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/socket/zipball/58e0422221825b79681b72c50c47a930be7bf1e1", + "reference": "58e0422221825b79681b72c50c47a930be7bf1e1", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/dns": "^2", + "ext-openssl": "*", + "kelunik/certificate": "^1.1", + "league/uri": "^6.5 | ^7", + "league/uri-interfaces": "^2.3 | ^7", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/process": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php", + "src/SocketAddress/functions.php" + ], + "psr-4": { + "Amp\\Socket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.", + "homepage": "https://github.com/amphp/socket", + "keywords": [ + "amp", + "async", + "encryption", + "non-blocking", + "sockets", + "tcp", + "tls" + ], + "support": { + "issues": "https://github.com/amphp/socket/issues", + "source": "https://github.com/amphp/socket/tree/v2.3.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-21T14:33:03+00:00" + }, + { + "name": "amphp/sync", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/sync.git", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Sync\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" + } + ], + "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.", + "homepage": "https://github.com/amphp/sync", + "keywords": [ + "async", + "asynchronous", + "mutex", + "semaphore", + "synchronization" + ], + "support": { + "issues": "https://github.com/amphp/sync/issues", + "source": "https://github.com/amphp/sync/tree/v2.3.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-08-03T19:31:26+00:00" + }, { "name": "aws/aws-crt-php", "version": "v1.2.4", @@ -155,6 +963,83 @@ }, "time": "2024-03-21T18:06:56+00:00" }, + { + "name": "barryvdh/laravel-dompdf", + "version": "v3.0.1", + "source": { + "type": "git", + "url": "https://github.com/barryvdh/laravel-dompdf.git", + "reference": "d2b3a158ba6e6c0fbb97208aa37dc764642ce5d5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/d2b3a158ba6e6c0fbb97208aa37dc764642ce5d5", + "reference": "d2b3a158ba6e6c0fbb97208aa37dc764642ce5d5", + "shasum": "" + }, + "require": { + "dompdf/dompdf": "^3.0", + "illuminate/support": "^9|^10|^11", + "php": "^8.1" + }, + "require-dev": { + "larastan/larastan": "^2.7.0", + "orchestra/testbench": "^7|^8|^9", + "phpro/grumphp": "^2.5", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "PDF": "Barryvdh\\DomPDF\\Facade\\Pdf", + "Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf" + }, + "providers": [ + "Barryvdh\\DomPDF\\ServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Barryvdh\\DomPDF\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "A DOMPDF Wrapper for Laravel", + "keywords": [ + "dompdf", + "laravel", + "pdf" + ], + "support": { + "issues": "https://github.com/barryvdh/laravel-dompdf/issues", + "source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.0.1" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2024-10-30T10:10:17+00:00" + }, { "name": "brick/math", "version": "0.11.0", @@ -418,6 +1303,50 @@ ], "time": "2023-08-31T09:50:34+00:00" }, + { + "name": "daverandom/libdns", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/DaveRandom/LibDNS.git", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "Required for IDN support" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "LibDNS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "DNS protocol implementation written in pure PHP", + "keywords": [ + "dns" + ], + "support": { + "issues": "https://github.com/DaveRandom/LibDNS/issues", + "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0" + }, + "time": "2024-04-12T12:12:48+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.2", @@ -999,11 +1928,166 @@ "type": "patreon" }, { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", - "type": "tidelift" + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "dompdf/dompdf", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "baf4084b27c7f4b5b7a221b19a94d11327664eb8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/baf4084b27c7f4b5b7a221b19a94d11327664eb8", + "reference": "baf4084b27c7f4b5b7a221b19a94d11327664eb8", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.0.2" + }, + "time": "2024-12-27T20:27:37+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d", + "reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^3 || ^4 || ^5 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.1" + }, + "time": "2024-12-02T14:37:59+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/eb045e518185298eb6ff8d80d0d0c6b17aecd9af", + "reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" } ], - "time": "2024-02-05T11:56:58+00:00" + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.0" + }, + "time": "2024-04-29T13:26:35+00:00" }, { "name": "dragonmantank/cron-expression", @@ -1806,6 +2890,114 @@ ], "time": "2023-12-03T19:50:20+00:00" }, + { + "name": "hollodotme/fast-cgi-client", + "version": "v3.1.7", + "source": { + "type": "git", + "url": "https://github.com/hollodotme/fast-cgi-client.git", + "reference": "062182d4eda73c161cc2839783acc83096ec0f37" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hollodotme/fast-cgi-client/zipball/062182d4eda73c161cc2839783acc83096ec0f37", + "reference": "062182d4eda73c161cc2839783acc83096ec0f37", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=7.1" + }, + "require-dev": { + "ext-xdebug": ">=2.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "hollodotme\\FastCGI\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Holger Woltersdorf", + "email": "hw@hollo.me" + } + ], + "description": "A PHP fast CGI client to send requests (a)synchronously to PHP-FPM.", + "keywords": [ + "Socket", + "async", + "fastcgi", + "php-fpm" + ], + "support": { + "issues": "https://github.com/hollodotme/fast-cgi-client/issues", + "source": "https://github.com/hollodotme/fast-cgi-client/tree/v3.1.7" + }, + "time": "2021-12-07T10:10:20+00:00" + }, + { + "name": "kelunik/certificate", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/kelunik/certificate.git", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">=7.0" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^6 | 7 | ^8 | ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Kelunik\\Certificate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Access certificate details and transform between different formats.", + "keywords": [ + "DER", + "certificate", + "certificates", + "openssl", + "pem", + "x509" + ], + "support": { + "issues": "https://github.com/kelunik/certificate/issues", + "source": "https://github.com/kelunik/certificate/tree/v1.1.3" + }, + "time": "2023-02-03T21:26:53+00:00" + }, { "name": "laravel/framework", "version": "v11.0.8", @@ -2257,6 +3449,86 @@ }, "time": "2024-01-04T16:10:04+00:00" }, + { + "name": "laravel/vapor-core", + "version": "v2.37.9", + "source": { + "type": "git", + "url": "https://github.com/laravel/vapor-core.git", + "reference": "9dde47564c2eba6ab489cc7fc728587920344aa2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/vapor-core/zipball/9dde47564c2eba6ab489cc7fc728587920344aa2", + "reference": "9dde47564c2eba6ab489cc7fc728587920344aa2", + "shasum": "" + }, + "require": { + "aws/aws-sdk-php": "^3.80", + "guzzlehttp/guzzle": "^6.3|^7.0", + "guzzlehttp/promises": "^1.4|^2.0", + "hollodotme/fast-cgi-client": "^3.0", + "illuminate/container": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", + "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", + "illuminate/queue": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", + "monolog/monolog": "^1.12|^2.0|^3.2", + "nyholm/psr7": "^1.0", + "php": "^7.2|^8.0", + "riverline/multipart-parser": "^2.0.9", + "symfony/process": "^4.3|^5.0|^6.0|^7.0", + "symfony/psr-http-message-bridge": "^1.0|^2.0|^6.4|^7.0" + }, + "require-dev": { + "laravel/octane": "*", + "mockery/mockery": "^1.2", + "orchestra/testbench": "^4.0|^5.0|^6.0|^7.0|^8.0|^9.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.0|^9.0|^10.4" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Vapor": "Laravel\\Vapor\\Vapor" + }, + "providers": [ + "Laravel\\Vapor\\VaporServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "files": [ + "src/debug.php" + ], + "psr-4": { + "Laravel\\Vapor\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The kernel and invocation handlers for Laravel Vapor", + "homepage": "https://github.com/laravel/vapor-core", + "keywords": [ + "laravel", + "vapor" + ], + "support": { + "source": "https://github.com/laravel/vapor-core/tree/v2.37.9" + }, + "time": "2025-01-07T23:06:04+00:00" + }, { "name": "lcobucci/clock", "version": "3.2.0", @@ -2853,6 +4125,180 @@ ], "time": "2024-01-28T23:22:08+00:00" }, + { + "name": "league/uri", + "version": "7.5.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "81fb5145d2644324614cc532b28efd0215bda430" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/81fb5145d2644324614cc532b28efd0215bda430", + "reference": "81fb5145d2644324614cc532b28efd0215bda430", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.5", + "php": "^8.1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", + "league/uri-components": "Needed to easily manipulate URI objects components", + "php-64bit": "to improve IPV4 host parsing", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.5.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2024-12-08T08:40:02+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.5.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-factory": "^1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common interfaces and classes for URI representation and interaction", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.5.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2024-12-08T08:18:47+00:00" + }, { "name": "maatwebsite/excel", "version": "3.1.55", @@ -3117,10 +4563,77 @@ "vector" ], "support": { - "issues": "https://github.com/MarkBaker/PHPMatrix/issues", - "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" + "issues": "https://github.com/MarkBaker/PHPMatrix/issues", + "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" + }, + "time": "2022-12-02T22:17:43+00:00" + }, + { + "name": "masterminds/html5", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", + "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.9.0" }, - "time": "2022-12-02T22:17:43+00:00" + "time": "2024-03-31T07:05:07+00:00" }, { "name": "monolog/monolog", @@ -3809,76 +5322,416 @@ "issues": "https://github.com/nunomaduro/termwind/issues", "source": "https://github.com/nunomaduro/termwind/tree/v2.0.1" }, - "funding": [ - { - "url": "https://www.paypal.com/paypalme/enunomaduro", - "type": "custom" - }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2024-03-06T16:17:14+00:00" + }, + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, + { + "name": "php-open-source-saver/jwt-auth", + "version": "2.2.2", + "source": { + "type": "git", + "url": "https://github.com/PHP-Open-Source-Saver/jwt-auth.git", + "reference": "195bd0c743daf5c6821cc3af73ee570b9c44c2c1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-Open-Source-Saver/jwt-auth/zipball/195bd0c743daf5c6821cc3af73ee570b9c44c2c1", + "reference": "195bd0c743daf5c6821cc3af73ee570b9c44c2c1", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/auth": "^6|^7|^8.67|^9|^10|^11", + "illuminate/contracts": "^6|^7|^8.67|^9|^10|^11", + "illuminate/http": "^6|^7|^8.67|^9|^10|^11", + "illuminate/support": "^6|^7|^8.67|^9|^10|^11", + "lcobucci/jwt": "^4.0", + "namshi/jose": "^7.0", + "nesbot/carbon": "^2.0|^3.0", + "php": "^7.4|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "illuminate/console": "^6|^7|^8.67|^9|^10|^11", + "illuminate/routing": "^6|^7|^8.67|^9|^10|^11", + "mockery/mockery": "^1.4.4", + "orchestra/testbench": "^4.18|^5.8|^6.3|^7|^8|^9|9.x-dev", + "phpstan/phpstan": "^1", + "phpunit/phpunit": "^8.5|^9.4|^10", + "vlucas/phpdotenv": "^5.2.0", + "yoast/phpunit-polyfills": "^2.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-develop": "2.0-dev" + }, + "laravel": { + "aliases": { + "JWTAuth": "PHPOpenSourceSaver\\JWTAuth\\Facades\\JWTAuth", + "JWTFactory": "PHPOpenSourceSaver\\JWTAuth\\Facades\\JWTFactory" + }, + "providers": [ + "PHPOpenSourceSaver\\JWTAuth\\Providers\\LaravelServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "PHPOpenSourceSaver\\JWTAuth\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sean Tymon", + "email": "tymon148@gmail.com", + "homepage": "https://tymon.xyz", + "role": "Forked package creator | Developer" + }, + { + "name": "Eric Schricker", + "email": "eric.schricker@adiutabyte.de", + "role": "Developer" + }, + { + "name": "Fabio William Conceição", + "email": "messhias@gmail.com", + "role": "Developer" + } + ], + "description": "JSON Web Token Authentication for Laravel and Lumen", + "homepage": "https://github.com/PHP-Open-Source-Saver/jwt-auth", + "keywords": [ + "Authentication", + "JSON Web Token", + "auth", + "jwt", + "laravel" + ], + "support": { + "issues": "https://github.com/PHP-Open-Source-Saver/jwt-auth/issues", + "source": "https://github.com/PHP-Open-Source-Saver/jwt-auth" + }, + "time": "2024-03-19T21:38:46+00:00" + }, + { + "name": "phpdocumentor/reflection", + "version": "6.1.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/Reflection.git", + "reference": "bb4dea805a645553d6d989b23dad9f8041f39502" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/Reflection/zipball/bb4dea805a645553d6d989b23dad9f8041f39502", + "reference": "bb4dea805a645553d6d989b23dad9f8041f39502", + "shasum": "" + }, + "require": { + "nikic/php-parser": "~4.18 || ^5.0", + "php": "8.1.*|8.2.*|8.3.*|8.4.*", + "phpdocumentor/reflection-common": "^2.1", + "phpdocumentor/reflection-docblock": "^5", + "phpdocumentor/type-resolver": "^1.2", + "symfony/polyfill-php80": "^1.28", + "webmozart/assert": "^1.7" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "doctrine/coding-standard": "^12.0", + "mikey179/vfsstream": "~1.2", + "mockery/mockery": "~1.6.0", + "phpspec/prophecy-phpunit": "^2.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^10.0", + "psalm/phar": "^5.24", + "rector/rector": "^1.0.0", + "squizlabs/php_codesniffer": "^3.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-5.x": "5.3.x-dev", + "dev-6.x": "6.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\": "src/phpDocumentor" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Reflection library to do Static Analysis for PHP Projects", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/Reflection/issues", + "source": "https://github.com/phpDocumentor/Reflection/tree/6.1.0" + }, + "time": "2024-11-22T15:11:54+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "5.6.1", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8", + "reference": "e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^1.7", + "phpstan/phpdoc-parser": "^1.7|^2.0", + "webmozart/assert": "^1.9.1" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ { - "url": "https://github.com/nunomaduro", - "type": "github" + "name": "Mike van Riel", + "email": "me@mikevanriel.com" }, { - "url": "https://github.com/xiCO2k", - "type": "github" + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" } ], - "time": "2024-03-06T16:17:14+00:00" + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.1" + }, + "time": "2024-12-07T09:39:29+00:00" }, { - "name": "php-open-source-saver/jwt-auth", - "version": "2.2.2", + "name": "phpdocumentor/type-resolver", + "version": "1.10.0", "source": { "type": "git", - "url": "https://github.com/PHP-Open-Source-Saver/jwt-auth.git", - "reference": "195bd0c743daf5c6821cc3af73ee570b9c44c2c1" + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-Open-Source-Saver/jwt-auth/zipball/195bd0c743daf5c6821cc3af73ee570b9c44c2c1", - "reference": "195bd0c743daf5c6821cc3af73ee570b9c44c2c1", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/679e3ce485b99e84c775d28e2e96fade9a7fb50a", + "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a", "shasum": "" }, "require": { - "ext-json": "*", - "illuminate/auth": "^6|^7|^8.67|^9|^10|^11", - "illuminate/contracts": "^6|^7|^8.67|^9|^10|^11", - "illuminate/http": "^6|^7|^8.67|^9|^10|^11", - "illuminate/support": "^6|^7|^8.67|^9|^10|^11", - "lcobucci/jwt": "^4.0", - "namshi/jose": "^7.0", - "nesbot/carbon": "^2.0|^3.0", - "php": "^7.4|^8.0" + "doctrine/deprecations": "^1.0", + "php": "^7.3 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^1.18|^2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3", - "illuminate/console": "^6|^7|^8.67|^9|^10|^11", - "illuminate/routing": "^6|^7|^8.67|^9|^10|^11", - "mockery/mockery": "^1.4.4", - "orchestra/testbench": "^4.18|^5.8|^6.3|^7|^8|^9|9.x-dev", - "phpstan/phpstan": "^1", - "phpunit/phpunit": "^8.5|^9.4|^10", - "vlucas/phpdotenv": "^5.2.0", - "yoast/phpunit-polyfills": "^2.0.0" + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpunit/phpunit": "^9.5", + "rector/rector": "^0.13.9", + "vimeo/psalm": "^4.25" }, "type": "library", "extra": { "branch-alias": { - "dev-develop": "2.0-dev" - }, - "laravel": { - "aliases": { - "JWTAuth": "PHPOpenSourceSaver\\JWTAuth\\Facades\\JWTAuth", - "JWTFactory": "PHPOpenSourceSaver\\JWTAuth\\Facades\\JWTFactory" - }, - "providers": [ - "PHPOpenSourceSaver\\JWTAuth\\Providers\\LaravelServiceProvider" - ] + "dev-1.x": "1.x-dev" } }, "autoload": { "psr-4": { - "PHPOpenSourceSaver\\JWTAuth\\": "src/" + "phpDocumentor\\Reflection\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -3887,36 +5740,16 @@ ], "authors": [ { - "name": "Sean Tymon", - "email": "tymon148@gmail.com", - "homepage": "https://tymon.xyz", - "role": "Forked package creator | Developer" - }, - { - "name": "Eric Schricker", - "email": "eric.schricker@adiutabyte.de", - "role": "Developer" - }, - { - "name": "Fabio William Conceição", - "email": "messhias@gmail.com", - "role": "Developer" + "name": "Mike van Riel", + "email": "me@mikevanriel.com" } ], - "description": "JSON Web Token Authentication for Laravel and Lumen", - "homepage": "https://github.com/PHP-Open-Source-Saver/jwt-auth", - "keywords": [ - "Authentication", - "JSON Web Token", - "auth", - "jwt", - "laravel" - ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { - "issues": "https://github.com/PHP-Open-Source-Saver/jwt-auth/issues", - "source": "https://github.com/PHP-Open-Source-Saver/jwt-auth" + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.10.0" }, - "time": "2024-03-19T21:38:46+00:00" + "time": "2024-11-09T15:12:26+00:00" }, { "name": "phpoffice/phpspreadsheet", @@ -4098,6 +5931,53 @@ ], "time": "2023-11-12T21:59:55+00:00" }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "c00d78fb6b29658347f9d37ebe104bffadf36299" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/c00d78fb6b29658347f9d37ebe104bffadf36299", + "reference": "c00d78fb6b29658347f9d37ebe104bffadf36299", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.0.0" + }, + "time": "2024-10-13T11:29:49+00:00" + }, { "name": "psr/cache", "version": "3.0.0", @@ -4817,51 +6697,244 @@ "vimeo/psalm": "^4.9" }, "suggest": { - "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", - "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", - "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", - "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", - "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.7.5" + }, + "funding": [ + { + "url": "https://github.com/ramsey", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/ramsey/uuid", + "type": "tidelift" + } + ], + "time": "2023-11-08T05:53:05+00:00" + }, + { + "name": "revolt/event-loop", + "version": "v1.0.6", + "source": { + "type": "git", + "url": "https://github.com/revoltphp/event-loop.git", + "reference": "25de49af7223ba039f64da4ae9a28ec2d10d0254" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/25de49af7223ba039f64da4ae9a28ec2d10d0254", + "reference": "25de49af7223ba039f64da4ae9a28ec2d10d0254", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-json": "*", + "jetbrains/phpstorm-stubs": "^2019.3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.15" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Revolt\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "ceesjank@gmail.com" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Rock-solid event loop for concurrent PHP applications.", + "keywords": [ + "async", + "asynchronous", + "concurrency", + "event", + "event-loop", + "non-blocking", + "scheduler" + ], + "support": { + "issues": "https://github.com/revoltphp/event-loop/issues", + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.6" + }, + "time": "2023-11-30T05:34:44+00:00" + }, + { + "name": "riverline/multipart-parser", + "version": "2.1.2", + "source": { + "type": "git", + "url": "https://github.com/Riverline/multipart-parser.git", + "reference": "7a9f4646db5181516c61b8e0225a343189beedcd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Riverline/multipart-parser/zipball/7a9f4646db5181516c61b8e0225a343189beedcd", + "reference": "7a9f4646db5181516c61b8e0225a343189beedcd", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=5.6.0" + }, + "require-dev": { + "laminas/laminas-diactoros": "^1.8.7 || ^2.11.1", + "phpunit/phpunit": "^5.7 || ^9.0", + "psr/http-message": "^1.0", + "symfony/psr-http-message-bridge": "^1.1 || ^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Riverline\\MultiPartParser\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Romain Cambien", + "email": "romain@cambien.net" + }, + { + "name": "Riverline", + "homepage": "http://www.riverline.fr" + } + ], + "description": "One class library to parse multipart content with encoding and charset support.", + "keywords": [ + "http", + "multipart", + "parser" + ], + "support": { + "issues": "https://github.com/Riverline/multipart-parser/issues", + "source": "https://github.com/Riverline/multipart-parser/tree/2.1.2" + }, + "time": "2024-03-12T16:46:05+00:00" + }, + { + "name": "sabberworm/php-css-parser", + "version": "v8.7.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "f414ff953002a9b18e3a116f5e462c56f21237cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/f414ff953002a9b18e3a116f5e462c56f21237cf", + "reference": "f414ff953002a9b18e3a116f5e462c56f21237cf", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^5.6.20 || ^7.0.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + }, + "require-dev": { + "phpunit/phpunit": "5.7.27 || 6.5.14 || 7.5.20 || 8.5.40" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" }, "type": "library", "extra": { - "captainhook": { - "force-install": true + "branch-alias": { + "dev-main": "9.0.x-dev" } }, "autoload": { - "files": [ - "src/functions.php" - ], "psr-4": { - "Ramsey\\Uuid\\": "src/" + "Sabberworm\\CSS\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", - "keywords": [ - "guid", - "identifier", - "uuid" - ], - "support": { - "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.7.5" - }, - "funding": [ + "authors": [ { - "url": "https://github.com/ramsey", - "type": "github" + "name": "Raphael Schweikert" }, { - "url": "https://tidelift.com/funding/github/packagist/ramsey/uuid", - "type": "tidelift" + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" } ], - "time": "2023-11-08T05:53:05+00:00" + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.7.0" + }, + "time": "2024-10-27T17:38:32+00:00" }, { "name": "spatie/enum", @@ -4999,6 +7072,90 @@ }, "time": "2024-05-16T15:11:32+00:00" }, + { + "name": "spatie/laravel-data", + "version": "4.11.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-data.git", + "reference": "df5b58baebae34475ca35338b4e9a131c9e2a8e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-data/zipball/df5b58baebae34475ca35338b4e9a131c9e2a8e0", + "reference": "df5b58baebae34475ca35338b4e9a131c9e2a8e0", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0|^11.0", + "php": "^8.1", + "phpdocumentor/reflection": "^6.0", + "spatie/laravel-package-tools": "^1.9.0", + "spatie/php-structure-discoverer": "^2.0" + }, + "require-dev": { + "fakerphp/faker": "^1.14", + "friendsofphp/php-cs-fixer": "^3.0", + "inertiajs/inertia-laravel": "^1.2", + "livewire/livewire": "^3.0", + "mockery/mockery": "^1.6", + "nesbot/carbon": "^2.63", + "nunomaduro/larastan": "^2.0", + "orchestra/testbench": "^8.0|^9.0", + "pestphp/pest": "^2.31", + "pestphp/pest-plugin-laravel": "^2.0", + "pestphp/pest-plugin-livewire": "^2.1", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpunit/phpunit": "^10.0", + "spatie/invade": "^1.0", + "spatie/laravel-typescript-transformer": "^2.5", + "spatie/pest-plugin-snapshots": "^2.1", + "spatie/test-time": "^1.2" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\LaravelData\\LaravelDataServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\LaravelData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ruben Van Assche", + "email": "ruben@spatie.be", + "role": "Developer" + } + ], + "description": "Create unified resources and data transfer objects", + "homepage": "https://github.com/spatie/laravel-data", + "keywords": [ + "laravel", + "laravel-data", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-data/issues", + "source": "https://github.com/spatie/laravel-data/tree/4.11.1" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2024-10-23T07:14:53+00:00" + }, { "name": "spatie/laravel-package-tools", "version": "1.16.4", @@ -5059,6 +7216,160 @@ ], "time": "2024-03-20T07:29:11+00:00" }, + { + "name": "spatie/laravel-webhook-server", + "version": "3.8.2", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-webhook-server.git", + "reference": "020e54e7f50bf80e738600872f8f0f149ce58efb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-webhook-server/zipball/020e54e7f50bf80e738600872f8f0f149ce58efb", + "reference": "020e54e7f50bf80e738600872f8f0f149ce58efb", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/guzzle": "^6.3|^7.3", + "illuminate/bus": "^8.50|^9.0|^10.0|^11.0", + "illuminate/queue": "^8.50|^9.0|^10.0|^11.0", + "illuminate/support": "^8.50|^9.0|^10.0|^11.0", + "php": "^8.0", + "spatie/laravel-package-tools": "^1.11" + }, + "require-dev": { + "mockery/mockery": "^1.4.3", + "orchestra/testbench": "^6.19|^7.0|^8.0", + "pestphp/pest": "^1.22|^2.0", + "pestphp/pest-plugin-laravel": "^1.3|^2.0", + "spatie/test-time": "^1.2.2" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\WebhookServer\\WebhookServerServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\WebhookServer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Send webhooks in Laravel apps", + "homepage": "https://github.com/spatie/laravel-webhook-server", + "keywords": [ + "laravel-webhook-server", + "server", + "spatie", + "webhook" + ], + "support": { + "source": "https://github.com/spatie/laravel-webhook-server/tree/3.8.2" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + } + ], + "time": "2024-12-16T12:56:30+00:00" + }, + { + "name": "spatie/php-structure-discoverer", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/php-structure-discoverer.git", + "reference": "42d161298630ede76c61e8a437a06eea2e106f4c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/42d161298630ede76c61e8a437a06eea2e106f4c", + "reference": "42d161298630ede76c61e8a437a06eea2e106f4c", + "shasum": "" + }, + "require": { + "amphp/amp": "^v3.0", + "amphp/parallel": "^2.2", + "illuminate/collections": "^10.0|^11.0", + "php": "^8.1", + "spatie/laravel-package-tools": "^1.4.3", + "symfony/finder": "^6.0|^7.0" + }, + "require-dev": { + "illuminate/console": "^10.0|^11.0", + "laravel/pint": "^1.0", + "nunomaduro/collision": "^7.0|^8.0", + "nunomaduro/larastan": "^2.0.1", + "orchestra/testbench": "^7.0|^8.0|^9.0", + "pestphp/pest": "^2.0", + "pestphp/pest-plugin-laravel": "^2.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^9.5|^10.0", + "spatie/laravel-ray": "^1.26" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\StructureDiscoverer\\StructureDiscovererServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\StructureDiscoverer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ruben Van Assche", + "email": "ruben@spatie.be", + "role": "Developer" + } + ], + "description": "Automatically discover structures within your PHP application", + "homepage": "https://github.com/spatie/php-structure-discoverer", + "keywords": [ + "discover", + "laravel", + "php", + "php-structure-discoverer" + ], + "support": { + "issues": "https://github.com/spatie/php-structure-discoverer/issues", + "source": "https://github.com/spatie/php-structure-discoverer/tree/2.3.0" + }, + "funding": [ + { + "url": "https://github.com/LaravelAutoDiscoverer", + "type": "github" + } + ], + "time": "2025-01-13T13:15:29+00:00" + }, { "name": "stripe/stripe-php", "version": "v10.21.0", @@ -6906,6 +9217,89 @@ ], "time": "2024-02-22T20:27:20+00:00" }, + { + "name": "symfony/psr-http-message-bridge", + "version": "v7.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/psr-http-message-bridge.git", + "reference": "03f2f72319e7acaf2a9f6fcbe30ef17eec51594f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/03f2f72319e7acaf2a9f6fcbe30ef17eec51594f", + "reference": "03f2f72319e7acaf2a9f6fcbe30ef17eec51594f", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/http-message": "^1.0|^2.0", + "symfony/http-foundation": "^6.4|^7.0" + }, + "conflict": { + "php-http/discovery": "<1.15", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "nyholm/psr7": "^1.1", + "php-http/discovery": "^1.15", + "psr/log": "^1.1.4|^2|^3", + "symfony/browser-kit": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\PsrHttpMessage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "PSR HTTP message bridge", + "homepage": "https://symfony.com", + "keywords": [ + "http", + "http-message", + "psr-17", + "psr-7" + ], + "support": { + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.2.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-26T08:57:56+00:00" + }, { "name": "symfony/routing", "version": "v7.0.5", @@ -10350,5 +12744,5 @@ "ext-intl": "*" }, "platform-dev": [], - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.2.0" } diff --git a/backend/config/app.php b/backend/config/app.php index 04ac89ac2f..036c59e034 100644 --- a/backend/config/app.php +++ b/backend/config/app.php @@ -8,13 +8,17 @@ 'reset_password_token_expiry_in_min' => 15, 'frontend_url' => env('APP_FRONTEND_URL', 'http://localhost'), + 'api_url' => env('APP_URL', 'https://localhost:8443'), 'cnd_url' => env('APP_CDN_URL', '/storage'), 'default_timezone' => 'America/Vancouver', 'default_currency_code' => 'USD', 'saas_mode_enabled' => env('APP_SAAS_MODE_ENABLED', false), 'saas_stripe_application_fee_percent' => env('APP_SAAS_STRIPE_APPLICATION_FEE_PERCENT', 1.5), + 'saas_stripe_application_fee_fixed' => env('APP_SAAS_STRIPE_APPLICATION_FEE_FIXED', 0), 'disable_registration' => env('APP_DISABLE_REGISTRATION', false), 'api_rate_limit_per_minute' => env('APP_API_RATE_LIMIT_PER_MINUTE', 180), + 'stripe_connect_account_type' => env('APP_STRIPE_CONNECT_ACCOUNT_TYPE', 'express'), + 'platform_support_email' => env('APP_PLATFORM_SUPPORT_EMAIL', 'support@hi.events'), /** * The number of page views to batch before updating the database @@ -24,12 +28,12 @@ 'homepage_views_update_batch_size' => env('APP_HOMEPAGE_VIEWS_UPDATE_BATCH_SIZE', 8), /** - * The number of seconds to cache the ticket quantities on the homepage + * The number of seconds to cache the product quantities on the homepage * It is recommended to cache this value for a short period of time for high traffic sites * * Set to null to disable caching */ - 'homepage_ticket_quantities_cache_ttl' => env('APP_HOMEPAGE_TICKET_QUANTITIES_CACHE_TTL', 2), + 'homepage_product_quantities_cache_ttl' => env('APP_HOMEPAGE_TICKET_QUANTITIES_CACHE_TTL', 2), 'frontend_urls' => [ 'confirm_email_address' => '/manage/profile/confirm-email-address/%s', @@ -39,7 +43,7 @@ 'stripe_connect_return_url' => '/account/payment', 'stripe_connect_refresh_url' => '/account/payment', 'event_homepage' => '/event/%d/%s', - 'attendee_ticket' => '/ticket/%d/%s', + 'attendee_product' => '/product/%d/%s', 'order_summary' => '/checkout/%d/%s/summary', 'organizer_order_summary' => '/manage/event/%d/orders#order-%d', ], diff --git a/backend/config/cors.php b/backend/config/cors.php index f6525c5b8b..b9018a8500 100644 --- a/backend/config/cors.php +++ b/backend/config/cors.php @@ -19,13 +19,13 @@ 'allowed_methods' => ['*'], - 'allowed_origins' => [env('APP_FRONTEND_URL', '*')], + 'allowed_origins' => explode(',', env('CORS_ALLOWED_ORIGINS', '*')), 'allowed_origins_patterns' => [], 'allowed_headers' => ['*'], - 'exposed_headers' => ['X-Auth-Token'], + 'exposed_headers' => ['X-Auth-Token', 'Set-Cookie'], 'max_age' => 0, diff --git a/backend/config/queue.php b/backend/config/queue.php index c8647ae353..c4260ae98f 100644 --- a/backend/config/queue.php +++ b/backend/config/queue.php @@ -1,6 +1,12 @@ env('WEBHOOK_QUEUE_NAME', env('QUEUE_CONNECTION', 'sync')), /* |-------------------------------------------------------------------------- diff --git a/backend/config/webhook-server.php b/backend/config/webhook-server.php new file mode 100644 index 0000000000..cd54853f1f --- /dev/null +++ b/backend/config/webhook-server.php @@ -0,0 +1,84 @@ + 'default', + + /* + * The default queue connection that should be used to send webhook requests. + */ + 'connection' => null, + + /* + * The default http verb to use. + */ + 'http_verb' => 'post', + + /* + * Proxies to use for request. + * + * See https://docs.guzzlephp.org/en/stable/request-options.html#proxy + */ + 'proxy' => null, + + /* + * This class is responsible for calculating the signature that will be added to + * the headers of the webhook request. A webhook client can use the signature + * to verify the request hasn't been tampered with. + */ + 'signer' => \Spatie\WebhookServer\Signer\DefaultSigner::class, + + /* + * This is the name of the header where the signature will be added. + */ + 'signature_header_name' => 'Signature', + + /* + * These are the headers that will be added to all webhook requests. + */ + 'headers' => [ + 'Content-Type' => 'application/json', + 'X-Webhook-Source' => 'Hi.Events', + ], + + /* + * If a call to a webhook takes longer this amount of seconds + * the attempt will be considered failed. + */ + 'timeout_in_seconds' => 3, + + /* + * The amount of times the webhook should be called before we give up. + */ + 'tries' => 3, + + /* + * This class determines how many seconds there should be between attempts. + */ + 'backoff_strategy' => \Spatie\WebhookServer\BackoffStrategy\ExponentialBackoffStrategy::class, + + /* + * This class is used to dispatch webhooks onto the queue. + */ + 'webhook_job' => \Spatie\WebhookServer\CallWebhookJob::class, + + /* + * By default we will verify that the ssl certificate of the destination + * of the webhook is valid. + */ + 'verify_ssl' => true, + + /* + * When set to true, an exception will be thrown when the last attempt fails + */ + 'throw_exception_on_failure' => false, + + /* + * When using Laravel Horizon you can specify tags that should be used on the + * underlying job that performs the webhook request. + */ + 'tags' => [], +]; diff --git a/backend/database/migrations/2024_09_20_032323_rename_tickets_to_products.php b/backend/database/migrations/2024_09_20_032323_rename_tickets_to_products.php new file mode 100644 index 0000000000..907f3fabba --- /dev/null +++ b/backend/database/migrations/2024_09_20_032323_rename_tickets_to_products.php @@ -0,0 +1,182 @@ +renameColumn('ticket_id', 'product_id'); + $table->renameColumn('ticket_price_id', 'product_price_id'); + }); + + Schema::table('attendees', function (Blueprint $table) { + $table->renameColumn('ticket_id', 'product_id'); + $table->renameColumn('ticket_price_id', 'product_price_id'); + }); + + Schema::table('product_prices', function (Blueprint $table) { + $table->renameColumn('ticket_id', 'product_id'); + }); + + Schema::table('product_taxes_and_fees', function (Blueprint $table) { + $table->renameColumn('ticket_id', 'product_id'); + }); + + Schema::table('product_questions', function (Blueprint $table) { + $table->renameColumn('ticket_id', 'product_id'); + }); + + Schema::table('product_check_in_lists', function (Blueprint $table) { + $table->renameColumn('ticket_id', 'product_id'); + }); + + Schema::table('attendee_check_ins', function (Blueprint $table) { + $table->renameColumn('ticket_id', 'product_id'); + }); + + Schema::table('product_capacity_assignments', function (Blueprint $table) { + $table->renameColumn('ticket_id', 'product_id'); + }); + + Schema::table('question_answers', function (Blueprint $table) { + $table->renameColumn('ticket_id', 'product_id'); + }); + + Schema::table('promo_codes', function (Blueprint $table) { + $table->renameColumn('applicable_ticket_ids', 'applicable_product_ids'); + }); + + Schema::table('event_statistics', function (Blueprint $table) { + $table->renameColumn('tickets_sold', 'products_sold'); + }); + + Schema::table('event_daily_statistics', function (Blueprint $table) { + $table->renameColumn('tickets_sold', 'products_sold'); + }); + + Schema::table('messages', function (Blueprint $table) { + $table->renameColumn('ticket_ids', 'product_ids'); + }); + + Schema::table('event_settings', function (Blueprint $table) { + $table->renameColumn('ticket_page_message', 'product_page_message'); + }); + + $this->renameIndex('idx_ticket_prices_ticket_id', 'idx_product_prices_product_id'); + $this->renameIndex('order_items_ticket_id_index', 'order_items_product_id_index'); + $this->renameIndex('order_items_ticket_price_id_index', 'order_items_product_price_id_index'); + $this->renameIndex('idx_attendees_ticket_id_deleted_at', 'idx_attendees_product_id_deleted_at'); + $this->renameIndex('ticket_tax_and_fees_ticket_id_index', 'product_tax_and_fees_product_id_index'); + $this->renameIndex('idx_ticket_questions_active', 'idx_product_questions_active'); + $this->renameIndex('ticket_check_in_lists_ticket_id_check_in_list_id_index', 'product_check_in_lists_product_id_check_in_list_id_index'); + $this->renameIndex('idx_ticket_check_in_lists_ticket_id_deleted_at', 'idx_product_check_in_lists_product_id_deleted_at'); + $this->renameIndex('attendee_check_ins_ticket_id_index', 'attendee_check_ins_product_id_index'); + $this->renameIndex('ticket_capacity_assignments_ticket_id_index', 'product_capacity_assignments_product_id_index'); + $this->renameIndex('attendees_ticket_prices_id_fk', 'attendees_product_prices_id_fk'); + } + + public function down(): void + { + Schema::rename('products', 'tickets'); + + Schema::rename('product_prices', 'ticket_prices'); + Schema::rename('product_taxes_and_fees', 'ticket_taxes_and_fees'); + Schema::rename('product_questions', 'ticket_questions'); + Schema::rename('product_check_in_lists', 'ticket_check_in_lists'); + Schema::rename('product_capacity_assignments', 'ticket_capacity_assignments'); + + // Rename sequences back + DB::statement('ALTER SEQUENCE product_capacity_assignments_id_seq RENAME TO ticket_capacity_assignments_id_seq'); + DB::statement('ALTER SEQUENCE product_check_in_lists_id_seq RENAME TO ticket_check_in_lists_id_seq'); + + Schema::table('order_items', function (Blueprint $table) { + $table->renameColumn('product_id', 'ticket_id'); + $table->renameColumn('product_price_id', 'ticket_price_id'); + }); + + Schema::table('attendees', function (Blueprint $table) { + $table->renameColumn('product_id', 'ticket_id'); + $table->renameColumn('product_price_id', 'ticket_price_id'); + }); + + Schema::table('ticket_prices', function (Blueprint $table) { + $table->renameColumn('product_id', 'ticket_id'); + }); + + Schema::table('ticket_taxes_and_fees', function (Blueprint $table) { + $table->renameColumn('product_id', 'ticket_id'); + }); + + Schema::table('ticket_questions', function (Blueprint $table) { + $table->renameColumn('product_id', 'ticket_id'); + }); + + Schema::table('ticket_check_in_lists', function (Blueprint $table) { + $table->renameColumn('product_id', 'ticket_id'); + }); + + Schema::table('attendee_check_ins', function (Blueprint $table) { + $table->renameColumn('product_id', 'ticket_id'); + }); + + Schema::table('ticket_capacity_assignments', function (Blueprint $table) { + $table->renameColumn('product_id', 'ticket_id'); + }); + + Schema::table('question_answers', function (Blueprint $table) { + $table->renameColumn('product_id', 'ticket_id'); + }); + + Schema::table('promo_codes', function (Blueprint $table) { + $table->renameColumn('applicable_product_ids', 'applicable_ticket_ids'); + }); + + Schema::table('event_statistics', function (Blueprint $table) { + $table->renameColumn('products_sold', 'tickets_sold'); + }); + + Schema::table('event_daily_statistics', function (Blueprint $table) { + $table->renameColumn('products_sold', 'tickets_sold'); + }); + + Schema::table('messages', function (Blueprint $table) { + $table->renameColumn('product_ids', 'ticket_ids'); + }); + + Schema::table('event_settings', function (Blueprint $table) { + $table->renameColumn('product_page_message', 'ticket_page_message'); + }); + + $this->renameIndex('idx_product_prices_product_id', 'idx_ticket_prices_ticket_id'); + $this->renameIndex('order_items_product_id_index', 'order_items_ticket_id_index'); + $this->renameIndex('order_items_product_price_id_index', 'order_items_ticket_price_id_index'); + $this->renameIndex('idx_attendees_product_id_deleted_at', 'idx_attendees_ticket_id_deleted_at'); + $this->renameIndex('product_tax_and_fees_product_id_index', 'ticket_tax_and_fees_ticket_id_index'); + $this->renameIndex('idx_product_questions_active', 'idx_ticket_questions_active'); + $this->renameIndex('product_check_in_lists_product_id_check_in_list_id_index', 'ticket_check_in_lists_ticket_id_check_in_list_id_index'); + $this->renameIndex('idx_product_check_in_lists_product_id_deleted_at', 'idx_ticket_check_in_lists_ticket_id_deleted_at'); + $this->renameIndex('attendee_check_ins_product_id_index', 'attendee_check_ins_ticket_id_index'); + $this->renameIndex('product_capacity_assignments_product_id_index', 'ticket_capacity_assignments_ticket_id_index'); + $this->renameIndex('attendees_product_prices_id_fk', 'attendees_ticket_prices_id_fk'); + } + + private function renameIndex($from, $to): void + { + DB::statement("ALTER INDEX IF EXISTS {$from} RENAME TO {$to}"); + } +}; diff --git a/backend/database/migrations/2024_09_20_032838_add_product_type_to_products.php b/backend/database/migrations/2024_09_20_032838_add_product_type_to_products.php new file mode 100644 index 0000000000..fd8974b75a --- /dev/null +++ b/backend/database/migrations/2024_09_20_032838_add_product_type_to_products.php @@ -0,0 +1,24 @@ +enum('product_type', ProductType::valuesArray()) + ->default(ProductType::TICKET->name) + ->after('id'); + }); + } + + public function down(): void + { + Schema::table('products', static function (Blueprint $table) { + $table->dropColumn('product_type'); + }); + } +}; diff --git a/backend/database/migrations/2024_09_23_032009_add_product_categories_table.php b/backend/database/migrations/2024_09_23_032009_add_product_categories_table.php new file mode 100644 index 0000000000..5a4b2f8824 --- /dev/null +++ b/backend/database/migrations/2024_09_23_032009_add_product_categories_table.php @@ -0,0 +1,68 @@ +id(); + $table->string('name'); + $table->string('no_products_message')->nullable(); + $table->string('description')->nullable(); + $table->boolean('is_hidden')->default(false); + $table->tinyInteger('order')->default(0); + + $table->unsignedBigInteger('event_id'); + $table->foreign('event_id')->references('id')->on('events')->onDelete('cascade'); + + $table->timestamps(); + $table->softDeletes(); + + $table->index('event_id'); + $table->index('is_hidden'); + $table->index('order'); + }); + + Schema::table('products', static function (Blueprint $table) { + $table->unsignedBigInteger('product_category_id')->nullable(); + $table->foreign('product_category_id')->references('id')->on('product_categories')->onDelete('set null'); + }); + + $events = DB::table('events')->get(); + + foreach ($events as $event) { + $categoryId = DB::table('product_categories')->insertGetId([ + 'name' => __('Tickets'), + 'event_id' => $event->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('products') + ->where('event_id', $event->id) + ->update(['product_category_id' => $categoryId]); + } + + DB::table('questions') + ->where('belongs_to', 'TICKET') + ->update(['belongs_to' => 'PRODUCT']); + } + + public function down(): void + { + Schema::table('products', static function (Blueprint $table) { + $table->dropForeign(['product_category_id']); + $table->dropColumn('product_category_id'); + }); + + Schema::dropIfExists('product_categories'); + + DB::table('questions') + ->where('belongs_to', 'PRODUCT') + ->update(['belongs_to' => 'TICKET']); + } +}; diff --git a/backend/database/migrations/2024_09_29_053757_add_product_type_to_order_items_table.php b/backend/database/migrations/2024_09_29_053757_add_product_type_to_order_items_table.php new file mode 100644 index 0000000000..79f7a26e7f --- /dev/null +++ b/backend/database/migrations/2024_09_29_053757_add_product_type_to_order_items_table.php @@ -0,0 +1,22 @@ +string('product_type')->default(ProductType::TICKET->name); + }); + } + + public function down(): void + { + Schema::table('order_items', function (Blueprint $table) { + $table->dropColumn('product_type'); + }); + } +}; diff --git a/backend/database/migrations/2024_10_01_003655_update_question_and_answer_views_view.php b/backend/database/migrations/2024_10_01_003655_update_question_and_answer_views_view.php new file mode 100644 index 0000000000..4f0db80e33 --- /dev/null +++ b/backend/database/migrations/2024_10_01_003655_update_question_and_answer_views_view.php @@ -0,0 +1,54 @@ +unsignedInteger('attendees_registered')->default(0); + }); + + DB::statement('UPDATE event_statistics SET attendees_registered = products_sold'); + } + + if (!Schema::hasColumn('event_daily_statistics', 'attendees_registered')) { + Schema::table('event_daily_statistics', static function (Blueprint $table) { + $table->unsignedInteger('attendees_registered')->default(0); + }); + + DB::statement('UPDATE event_daily_statistics SET attendees_registered = products_sold'); + } + } + + public function down(): void + { + if (Schema::hasColumn('event_statistics', 'attendees_registered')) { + Schema::table('event_statistics', static function (Blueprint $table) { + $table->dropColumn('attendees_registered'); + }); + } + + if (Schema::hasColumn('event_daily_statistics', 'attendees_registered')) { + Schema::table('event_daily_statistics', static function (Blueprint $table) { + $table->dropColumn('attendees_registered'); + }); + } + } +}; diff --git a/backend/database/migrations/2024_10_14_232118_add_start_collapsed_to_tickets.php b/backend/database/migrations/2024_10_14_232118_add_start_collapsed_to_tickets.php index a453959c92..480a9fe31b 100644 --- a/backend/database/migrations/2024_10_14_232118_add_start_collapsed_to_tickets.php +++ b/backend/database/migrations/2024_10_14_232118_add_start_collapsed_to_tickets.php @@ -2,21 +2,25 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; -return new class extends Migration -{ +return new class extends Migration { public function up(): void { - Schema::table('tickets', static function (Blueprint $table) { - $table->boolean('start_collapsed')->default(false); - }); + $table = Schema::hasTable('tickets') ? 'tickets' : 'products'; + + if (!Schema::hasColumn($table, 'start_collapsed')) { + Schema::table($table, static function (Blueprint $table) { + $table->boolean('start_collapsed')->default(false); + }); + } } public function down(): void { - Schema::table('tickets', static function (Blueprint $table) { + $table = Schema::hasTable('tickets') ? 'tickets' : 'products'; + + Schema::table($table, static function (Blueprint $table) { $table->dropColumn('start_collapsed'); }); } diff --git a/backend/database/migrations/2024_11_22_235559_update_capacity_assignment_applies_to.php b/backend/database/migrations/2024_11_22_235559_update_capacity_assignment_applies_to.php new file mode 100644 index 0000000000..2e1293ea41 --- /dev/null +++ b/backend/database/migrations/2024_11_22_235559_update_capacity_assignment_applies_to.php @@ -0,0 +1,20 @@ +text('notes')->nullable(); + }); + } + + public function down(): void + { + Schema::table('attendees', static function (Blueprint $table) { + if (!Schema::hasColumn('attendees', 'notes')) { + return; + } + $table->dropColumn('notes'); + }); + } +}; diff --git a/backend/database/migrations/2025_01_01_232333_add_event_invoicing_settings.php b/backend/database/migrations/2025_01_01_232333_add_event_invoicing_settings.php new file mode 100644 index 0000000000..da27fea0f0 --- /dev/null +++ b/backend/database/migrations/2025_01_01_232333_add_event_invoicing_settings.php @@ -0,0 +1,42 @@ +boolean('enable_invoicing')->default(false); + $table->string('invoice_label')->nullable(); + $table->string('invoice_prefix')->nullable(); + $table->unsignedInteger('invoice_start_number')->default(1); + $table->boolean('require_billing_address')->default(true); + $table->string('organization_name')->nullable(); + $table->text('organization_address')->nullable(); + $table->text('invoice_tax_details')->nullable(); + $table->json('payment_providers')->nullable(); + $table->text('offline_payment_instructions')->nullable(); + }); + } + + public function down(): void + { + Schema::table('event_settings', static function (Blueprint $table) { + $table->dropColumn([ + 'enable_invoicing', + 'invoice_label', + 'invoice_prefix', + 'invoice_start_number', + 'require_billing_address', + 'organization_name', + 'organization_address', + 'invoice_tax_details', + 'payment_providers', + 'offline_payment_instructions' + ]); + }); + } +}; diff --git a/backend/database/migrations/2025_01_03_010511_create_invoices_table.php b/backend/database/migrations/2025_01_03_010511_create_invoices_table.php new file mode 100644 index 0000000000..d8024b6ab3 --- /dev/null +++ b/backend/database/migrations/2025_01_03_010511_create_invoices_table.php @@ -0,0 +1,35 @@ +bigIncrements('id'); + $table->unsignedBigInteger('order_id'); + $table->unsignedBigInteger('account_id'); + $table->string('invoice_number', 50); + $table->timestamp('issue_date')->useCurrent(); + $table->timestamp('due_date')->nullable(); + $table->decimal('total_amount', 14, 2); + $table->string('status', 20)->default('PENDING'); + $table->jsonb('items'); + $table->jsonb('taxes_and_fees')->nullable(); + $table->uuid()->default(DB::raw('gen_random_uuid()')); + $table->timestamps(); + $table->softDeletes(); + + $table->foreign('order_id')->references('id')->on('orders')->onDelete('cascade'); + $table->foreign('account_id')->references('id')->on('accounts')->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('invoices'); + } +}; diff --git a/backend/database/migrations/2025_01_03_013621_add_payment_provider_to_orders_table.php b/backend/database/migrations/2025_01_03_013621_add_payment_provider_to_orders_table.php new file mode 100644 index 0000000000..f47d681ffa --- /dev/null +++ b/backend/database/migrations/2025_01_03_013621_add_payment_provider_to_orders_table.php @@ -0,0 +1,28 @@ +string('payment_provider')->nullable(); + }); + + DB::table('orders') + ->where('total_gross', '>', 0) + ->whereNull('payment_provider') + ->update(['payment_provider' => PaymentProviders::STRIPE->name]); + } + + public function down(): void + { + Schema::table('orders', static function (Blueprint $table) { + $table->dropColumn('payment_provider'); + }); + } +}; diff --git a/backend/database/migrations/2025_01_10_144325_add_index_to_orders_status.php b/backend/database/migrations/2025_01_10_144325_add_index_to_orders_status.php new file mode 100644 index 0000000000..e2724caf76 --- /dev/null +++ b/backend/database/migrations/2025_01_10_144325_add_index_to_orders_status.php @@ -0,0 +1,23 @@ +boolean('allow_orders_awaiting_offline_payment_to_check_in')->default(false); + }); + } + + public function down(): void + { + Schema::table('event_settings', static function (Blueprint $table) { + $table->dropColumn('allow_orders_awaiting_offline_payment_to_check_in'); + }); + } +}; diff --git a/backend/database/migrations/2025_01_19_042750_add_notes_to_orders_table.php b/backend/database/migrations/2025_01_19_042750_add_notes_to_orders_table.php new file mode 100644 index 0000000000..08645adee3 --- /dev/null +++ b/backend/database/migrations/2025_01_19_042750_add_notes_to_orders_table.php @@ -0,0 +1,22 @@ +text('notes')->nullable(); + }); + } + + public function down(): void + { + Schema::table('orders', static function (Blueprint $table) { + $table->dropColumn('notes'); + }); + } +}; diff --git a/backend/database/migrations/2025_01_19_181257_add_payment_terms_and_notes_to_invoices_table.php b/backend/database/migrations/2025_01_19_181257_add_payment_terms_and_notes_to_invoices_table.php new file mode 100644 index 0000000000..fc74c532fa --- /dev/null +++ b/backend/database/migrations/2025_01_19_181257_add_payment_terms_and_notes_to_invoices_table.php @@ -0,0 +1,23 @@ +integer('invoice_payment_terms_days')->nullable(); + $table->text('invoice_notes')->nullable(); + }); + } + + public function down(): void + { + Schema::table('event_settings', static function (Blueprint $table) { + $table->dropColumn('invoice_payment_terms_days'); + $table->dropColumn('invoice_notes'); + }); + } +}; diff --git a/backend/database/migrations/2025_01_20_035401_add_default_payment_provider.php b/backend/database/migrations/2025_01_20_035401_add_default_payment_provider.php new file mode 100644 index 0000000000..4a58b8f898 --- /dev/null +++ b/backend/database/migrations/2025_01_20_035401_add_default_payment_provider.php @@ -0,0 +1,19 @@ +whereNull('payment_providers') + ->update(['payment_providers' => [PaymentProviders::STRIPE->name]]); + } + + public function down(): void + { + // noop + } +}; diff --git a/backend/database/migrations/2025_01_20_045159_add_configuration_to_accounts.php b/backend/database/migrations/2025_01_20_045159_add_configuration_to_accounts.php new file mode 100644 index 0000000000..3d41ccc670 --- /dev/null +++ b/backend/database/migrations/2025_01_20_045159_add_configuration_to_accounts.php @@ -0,0 +1,29 @@ +json('configuration')->nullable(); + }); + + DB::table('accounts')->update(['configuration' => [ + 'application_fee' => [ + 'percentage' => config('app.saas_stripe_application_fee_percent'), + 'fixed' => config('app.saas_stripe_application_fee_fixed') ?? 0, + ] + ]]); + } + + public function down(): void + { + Schema::table('accounts', static function (Blueprint $table) { + $table->dropColumn('configuration'); + }); + } +}; diff --git a/backend/database/migrations/2025_02_02_093202_create_order_refunds_table.php b/backend/database/migrations/2025_02_02_093202_create_order_refunds_table.php new file mode 100644 index 0000000000..40e956c135 --- /dev/null +++ b/backend/database/migrations/2025_02_02_093202_create_order_refunds_table.php @@ -0,0 +1,34 @@ +increments('id'); + $table->foreignId('order_id')->constrained('orders')->onDelete('cascade'); + + $table->string('payment_provider'); + $table->string('refund_id')->comment('The refund ID from the payment provider'); + $table->decimal('amount', 14, 2); + $table->string('currency', 10); + $table->string('status', 20)->nullable(); + $table->text('reason')->nullable(); + $table->jsonb('metadata')->nullable(); + + $table->timestamps(); + $table->softDeletes(); + + $table->index('refund_id'); + $table->index('order_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('order_refunds'); + } +}; diff --git a/backend/database/migrations/2025_02_03_112136_add_stripe_connect_account_type_to_accounts.php b/backend/database/migrations/2025_02_03_112136_add_stripe_connect_account_type_to_accounts.php new file mode 100644 index 0000000000..551c9b056a --- /dev/null +++ b/backend/database/migrations/2025_02_03_112136_add_stripe_connect_account_type_to_accounts.php @@ -0,0 +1,27 @@ +string('stripe_connect_account_type')->nullable(); + }); + + DB::table('accounts') + ->whereNotNull('stripe_account_id') + ->update(['stripe_connect_account_type' => StripeConnectAccountType::EXPRESS->value]); + } + + public function down(): void + { + Schema::table('accounts', static function (Blueprint $table) { + $table->dropColumn('stripe_connect_account_type'); + }); + } +}; diff --git a/backend/database/migrations/2025_02_03_121516_add_stripe_account_id_to_stripe_customers_table.php b/backend/database/migrations/2025_02_03_121516_add_stripe_account_id_to_stripe_customers_table.php new file mode 100644 index 0000000000..6bd5b9eadf --- /dev/null +++ b/backend/database/migrations/2025_02_03_121516_add_stripe_account_id_to_stripe_customers_table.php @@ -0,0 +1,27 @@ +string('stripe_account_id')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('stripe_customers', function (Blueprint $table) { + $table->dropColumn('stripe_account_id'); + }); + } +}; diff --git a/backend/database/migrations/2025_02_09_074739_create_webhooks_table.php b/backend/database/migrations/2025_02_09_074739_create_webhooks_table.php new file mode 100644 index 0000000000..236d69e392 --- /dev/null +++ b/backend/database/migrations/2025_02_09_074739_create_webhooks_table.php @@ -0,0 +1,57 @@ +id(); + + $table->string('url'); + $table->jsonb('event_types'); + $table->integer('last_response_code')->nullable(); + $table->text('last_response_body')->nullable(); + $table->timestamp('last_triggered_at')->nullable(); + $table->string('status')->default(WebhookStatus::ENABLED->name); + $table->string('secret'); + + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('event_id')->constrained()->onDelete('cascade'); + $table->foreignId('account_id')->constrained()->onDelete('cascade'); + + $table->index('event_id'); + $table->index('account_id'); + $table->index('status'); + + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('webhook_logs', static function (Blueprint $table) { + $table->id(); + + $table->text('payload'); + $table->string('event_type'); + $table->integer('response_code')->nullable(); + $table->text('response_body')->nullable(); + + $table->foreignId('webhook_id')->constrained()->onDelete('cascade'); + + $table->index('event_type'); + $table->index('webhook_id'); + + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('webhook_logs'); + Schema::dropIfExists('webhooks'); + } +}; diff --git a/backend/database/migrations/2025_02_16_162629_add_application_fee_to_stripe_payments.php b/backend/database/migrations/2025_02_16_162629_add_application_fee_to_stripe_payments.php new file mode 100644 index 0000000000..84c888b9aa --- /dev/null +++ b/backend/database/migrations/2025_02_16_162629_add_application_fee_to_stripe_payments.php @@ -0,0 +1,22 @@ +bigInteger('application_fee') + ->default(0); + }); + } + + public function down(): void + { + Schema::table('stripe_payments', static function (Blueprint $table) { + $table->dropColumn('application_fee'); + }); + } +}; diff --git a/backend/database/migrations/2025_02_16_163546_create_account_configuration.php b/backend/database/migrations/2025_02_16_163546_create_account_configuration.php new file mode 100644 index 0000000000..e39f336a73 --- /dev/null +++ b/backend/database/migrations/2025_02_16_163546_create_account_configuration.php @@ -0,0 +1,55 @@ +id(); + $table->string('name'); + $table->boolean('is_system_default')->default(false); + $table->json('application_fees')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + $defaultConfigId = DB::table('account_configuration')->insertGetId([ + '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), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + Schema::table('accounts', function (Blueprint $table) { + $table->foreignId('account_configuration_id') + ->nullable() + ->constrained('account_configuration') + ->onDelete('set null'); + }); + + DB::table('accounts')->update(['account_configuration_id' => $defaultConfigId]); + + Schema::table('accounts', function (Blueprint $table) { + $table->dropColumn('configuration'); + }); + } + + public function down(): void + { + Schema::table('accounts', function (Blueprint $table) { + $table->dropForeign(['account_configuration_id']); + $table->dropColumn('account_configuration_id'); + $table->json('configuration')->nullable(); + }); + + Schema::dropIfExists('account_configuration'); + } +}; diff --git a/backend/database/migrations/2025_02_17_095041_create_order_application_fees_table.php b/backend/database/migrations/2025_02_17_095041_create_order_application_fees_table.php new file mode 100644 index 0000000000..15b99cea81 --- /dev/null +++ b/backend/database/migrations/2025_02_17_095041_create_order_application_fees_table.php @@ -0,0 +1,33 @@ +id(); + + $table->foreignId('order_id') + ->constrained('orders') + ->onDelete('cascade'); + $table->decimal('amount', 10, 2); + $table->string('currency', 10)->default('USD'); + $table->string('status', 20); + $table->string('payment_method', 50); + $table->json('metadata')->nullable(); + $table->timestamp('paid_at')->nullable(); + + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('order_application_fees'); + } +}; + diff --git a/backend/database/migrations/2025_02_23_181633_add_is_manually_verified_to_accounts.php b/backend/database/migrations/2025_02_23_181633_add_is_manually_verified_to_accounts.php new file mode 100644 index 0000000000..128450aa16 --- /dev/null +++ b/backend/database/migrations/2025_02_23_181633_add_is_manually_verified_to_accounts.php @@ -0,0 +1,21 @@ +boolean('is_manually_verified')->default(false); + }); + } + + public function down(): void + { + Schema::table('accounts', static function (Blueprint $table) { + $table->dropColumn('is_manually_verified'); + }); + } +}; diff --git a/backend/lang/de.json b/backend/lang/de.json index 318fe6a979..a9470900b7 100644 --- a/backend/lang/de.json +++ b/backend/lang/de.json @@ -1,18 +1,18 @@ { - "Older First": "Ältere Erste", - "Newer First": "Neuere Erste", - "Recently Updated First": "Kürzlich aktualisiert Erste", - "Recently Updated Last": "Zuletzt aktualisiert Letzte", - "First Name A-Z": "Vornamen A-Z", + "Older First": "Ältere zuerst", + "Newer First": "Neuere zuerst", + "Recently Updated First": "Kürzlich aktualisiert", + "Recently Updated Last": "Zuletzt aktualisiert", + "First Name A-Z": "Vorname A-Z", "First Name Z-A": "Vorname Z-A", "Last Name A-Z": "Nachname A-Z", "Last Name Z-A": "Nachname Z-A", "Status A-Z": "Status A-Z", "Status Z-A": "Status Z-A", "Closest start date": "Nächstes Startdatum", - "Furthest start date": "Weitestes Startdatum", + "Furthest start date": "Spätestes Startdatum", "Closest end date": "Nächstes Enddatum", - "Furthest end date": "Weitestes Enddatum", + "Furthest end date": "Spätestes Enddatum", "Newest first": "Neuestes zuerst", "Oldest first": "Älteste zuerst", "Recently Updated": "Kürzlich aktualisiert", @@ -26,16 +26,16 @@ "Buyer Email A-Z": "Käufer E-Mail A-Z", "Buyer Email Z-A": "Käufer E-Mail Z-A", "Order # Ascending": "Bestellnummer aufsteigend", - "Order # Descending": "Bestellung # Absteigend", + "Order # Descending": "Spätestes Absteigend", "Code Name A-Z": "Codename A-Z", "Code Name Z-A": "Codename Z-A", "Usage Count Ascending": "Verwendung Anzahl aufsteigend", - "Usage Count Descending": "Anzahl der Verwendungen absteigend", + "Usage Count Descending": "Verwendung Anzahl absteigend", "Homepage order": "Homepage bestellen", "Title A-Z": "Titel A-Z", "Title Z-A": "Titel Z-A", "Sale start date closest": "Nächster Termin für den Verkaufsstart", - "Sale start date furthest": "Datum des Verkaufsbeginns am weitesten entfernt", + "Sale start date furthest": "Letzter Termin für den Verkaufsstart", "Sale end date closest": "Das nächstgelegene Enddatum des Verkaufs", "Sale end date furthest": "Enddatum des Verkaufs am weitesten entfernt", "Account registration is disabled": "Kontoregistrierung ist deaktiviert", @@ -82,7 +82,7 @@ "Password reset": "Passwort zurücksetzen", "Your password has been reset": "Ihr Passwort wurde zurückgesetzt", "You\\'ve been invited to join :appName": "Sie wurden zum Beitritt eingeladen :appName", - "Current account ID is not set": "Girokonto-ID ist nicht eingestellt", + "Current account ID is not set": "Account-ID ist nicht eingestellt", "User not found in this account": "Benutzer nicht in diesem Konto gefunden", "User not found": "Benutzer nicht gefunden", "Username or Password are incorrect": "Benutzername oder Passwort sind falsch", @@ -156,8 +156,8 @@ "The email :email already exists on this account": "Die E-Mail :email existiert bereits für dieses Konto", "You are not authorized to perform this action.": "Sie sind nicht berechtigt, diese Aktion durchzuführen.", "Your account is not active.": "Ihr Konto ist nicht aktiv.", - "Payload has expired or is invalid.": "Die Nutzlast ist abgelaufen oder ungültig.", - "Payload could not be decrypted.": "Die Nutzlast konnte nicht entschlüsselt werden.", + "Payload has expired or is invalid.": "Nutzdaten sind abgelaufen oder ungültig.", + "Payload could not be decrypted.": "Die Nutzdaten konnte nicht entschlüsselt werden.", "Could not upload image to :disk. Check :disk is configured correctly": "Das Bild konnte nicht auf :disk hochgeladen werden. Prüfen Sie, ob :disk richtig konfiguriert ist", "Could not upload image": "Bild konnte nicht hochgeladen werden", "Length must be a positive integer.": "Die Länge muss eine positive ganze Zahl sein.", @@ -165,7 +165,7 @@ "A valid email is required": "Eine gültige E-Mail ist erforderlich", "The title field is required": "Das Feld Titel ist erforderlich", "The attribute name is required": "Das Attribut name ist erforderlich", - "The attribute value is required": "Der Wert des Attributs ist erforderlich", + "The attribute value is required": "Das Attribut value ist erforderlich", "The attribute is_public fields is required": "Das Attribut is_public fields ist erforderlich", "Required questions have not been answered. You may need to reload the page.": "Die erforderlichen Fragen wurden nicht beantwortet. Möglicherweise müssen Sie die Seite neu laden.", "This question is outdated. Please reload the page.": "Diese Frage ist veraltet. Bitte laden Sie die Seite neu.", @@ -178,17 +178,17 @@ "Please click the link below to reset your password.": "Bitte klicken Sie auf den untenstehenden Link, um Ihr Passwort zurückzusetzen.", "Reset Password": "Passwort zurücksetzen", "If you did not request a password reset, please ignore this email or reply to let us know.": "Wenn Sie keine Passwortrücksetzung angefordert haben, ignorieren Sie bitte diese E-Mail oder antworten Sie uns, um uns zu informieren.", - "Thank you": "Dankeschön", + "Thank you": "Vielen Dank", "Your password has been reset for your account on :appName.": "Ihr Passwort wurde für Ihr Konto auf :appName zurückgesetzt.", "If you did not request a password reset, please immediately contact reset your password.": "Wenn Sie keine Rücksetzung des Kennworts beantragt haben, setzen Sie sich bitte umgehend mit uns in Verbindung, um Ihr Kennwort zurückzusetzen.", "You are receiving this communication because you are registered as an attendee for the following event:": "Sie erhalten diese Mitteilung, weil Sie als Teilnehmer für die folgende Veranstaltung registriert sind:", "If you believe you have received this email in error,": "Wenn Sie glauben, dass Sie diese E-Mail irrtümlich erhalten haben,", "please contact the event organizer at": "wenden Sie sich bitte an den Organisator der Veranstaltung", - "If you believe this is spam, please report it to": "Wenn Sie glauben, dass dies Spam ist, melden Sie es bitte an", - "You\\'re going to": "Sie werden", + "If you believe this is spam, please report it to": "Wenn Sie glauben, dass dies Spam ist, melden Sie das bitte an", + "You\\'re going to": "Sie gehen nach", "Please find your ticket details below.": "Nachstehend finden Sie die Einzelheiten zu Ihren Tickets.", "View Ticket": "Ticket ansehen", - "If you have any questions or need assistance, please reply to this email or contact the event organizer": "Wenn Sie Fragen haben oder Hilfe benötigen, antworten Sie bitte auf diese E-Mail oder wenden Sie sich an den Organisator der Veranstaltung", + "If you have any questions or need assistance, please reply to this email or contact the event organizer": "Wenn Sie Fragen haben oder Hilfe benötigen, antworten Sie bitte auf diese E-Mail oder wenden Sie sich an den Veranstalter", "at": "unter", "Best regards,": "Mit freundlichen Grüßen,", "Your order for": "Ihre Bestellung für", @@ -197,7 +197,7 @@ "If you have any questions or need assistance, please respond to this email.": "Wenn Sie Fragen haben oder Hilfe benötigen, antworten Sie bitte auf diese E-Mail.", "Your recent order for": "Ihr aktueller Auftrag für", "was not successful.": "war nicht erfolgreich.", - "View Event Homepage": "Veranstaltungs-Homepage ansehen", + "View Event Homepage": "Veranstaltungs-Webseite ansehen", "If you have any questions or need assistance, feel free to reach out to our support team": "Wenn Sie Fragen haben oder Hilfe benötigen, wenden Sie sich bitte an unser Support-Team", "Best regards": "Mit freundlichen Grüßen", "You have received a refund of :refundAmount for the following event: :eventTitle.": "Sie haben eine Erstattung in Höhe von :refundAmount für das folgende Ereignis erhalten: :eventTitle.", @@ -230,9 +230,9 @@ "Prepare for the Event:": "Bereiten Sie sich auf das Ereignis vor:", "Make sure to note the event date, time, and location.": "Achten Sie darauf, dass Sie Datum, Uhrzeit und Ort der Veranstaltung angeben.", "Stay Updated:": "Bleiben Sie auf dem Laufenden:", - "Keep an eye on your email for any updates from the event organizer.": "Behalten Sie Ihre E-Mail im Auge, wenn Sie von den Veranstaltern neue Informationen erhalten.", + "Keep an eye on your email for any updates from the event organizer.": "Achten Sie auf Ihre E-Emails für weitere Informationen vom Veranstalter.", "Hi :name": "Hallo :name", - "Welcome to :appName! We\\'re excited to have you aboard!": "Willkommen bei :appName! Wir freuen uns, Sie an Bord zu haben!", + "Welcome to :appName! We\\'re excited to have you aboard!": "Willkommen bei :appName! Wir freuen uns, Sie begrüßen zu dürfen!", "To get started and activate your account, please click the link below to confirm your email address:": "Um zu beginnen und Ihr Konto zu aktivieren, klicken Sie bitte auf den unten stehenden Link, um Ihre E-Mail-Adresse zu bestätigen:", "Confirm Your Email": "Bestätigen Sie Ihre E-Mail", "If you did not create an account with us, no further action is required. Your email address will not be used without confirmation.": "Wenn Sie kein Konto bei uns erstellt haben, sind keine weiteren Schritte erforderlich. Ihre E-Mail-Adresse wird ohne Bestätigung nicht verwendet.", @@ -283,5 +283,142 @@ "Attendee does not belong to this check-in list": "Teilnehmer gehört nicht zu dieser Eincheckliste", "Attendee :attendee_name\\'s ticket is cancelled": "Das Ticket von Teilnehmer :attendee_name wurde storniert", "Check-in list is not active yet": "Die Check-in-Liste ist noch nicht aktiv", - "The number of attendees does not match the number of tickets in the order": "" -} \ No newline at end of file + "The number of attendees does not match the number of tickets in the order": "Die Anzahl der Teilnehmer stimmt nicht mit der Anzahl der Tickets in der Bestellung überein.", + "Product is required": "Produkt ist erforderlich.", + "Product price is required": "Produktpreis ist erforderlich.", + "Please select at least one product.": "Bitte wählen Sie mindestens ein Produkt aus.", + "The sale start date must be after the product sale start date.": "Das Verkaufsstartdatum muss nach dem Verkaufsstartdatum des Produkts liegen.", + "You must select a product category.": "Sie müssen eine Produktkategorie auswählen.", + "Invalid direction. Must be either asc or desc": "Ungültige Richtung. Muss entweder asc oder desc sein.", + "DomainObject must be a valid :interface.": "DomainObject muss eine gültige :interface sein.", + "Nested relationships must be an array of Relationship objects.": "Verschachtelte Beziehungen müssen ein Array von Relationship-Objekten sein.", + "OrderAndDirections must be an array of OrderAndDirection objects.": "OrderAndDirections muss ein Array von OrderAndDirection-Objekten sein.", + "Attendee :attendee_name\\'s product is cancelled": "Das Produkt von Teilnehmer :attendee_name wurde storniert.", + "Tickets": "Tickets", + "There are no tickets available for this event.": "Es sind keine Tickets für diese Veranstaltung verfügbar.", + "You haven\\'t selected any products": "Sie haben keine Produkte ausgewählt.", + "The maximum number of products available for :products is :max": "Die maximale Anzahl der verfügbaren Produkte für :products beträgt :max.", + "You must order at least :min products for :product": "Sie müssen mindestens :min Produkte für :product bestellen.", + "The product :product is sold out": "Das Produkt :product ist ausverkauft.", + "The maximum number of products available for :product is :max": "Die maximale Anzahl der verfügbaren Produkte für :product beträgt :max.", + "Sorry, these products are sold out": "Entschuldigung, diese Produkte sind ausverkauft.", + "The maximum number of products available is :max": "Die maximale Anzahl der verfügbaren Produkte beträgt :max.", + "Product with id :id not found": "Produkt mit ID :id nicht gefunden.", + "You cannot delete this product because it has orders associated with it. You can hide it instead.": "Sie können dieses Produkt nicht löschen, da damit Bestellungen verknüpft sind. Sie können es stattdessen ausblenden.", + "Invalid product ids: :ids": "Ungültige Produkt-IDs: :ids.", + "Product is hidden without promo code": "Produkt ist ohne Aktionscode verborgen.", + "Product is sold out": "Produkt ist ausverkauft.", + "Product is before sale start date": "Produkt ist vor dem Verkaufsstartdatum.", + "Product is after sale end date": "Produkt ist nach dem Verkaufsendedatum.", + "Product is hidden": "Produkt ist verborgen.", + "Cannot delete product price with id :id because it has sales": "Produktpreis mit ID :id kann nicht gelöscht werden, da es Verkäufe gibt.", + "You cannot delete this product category because it contains the following products: :products. These products are linked to existing orders. Please move the :product_name to another category before attempting to delete this one.": "Sie können diese Produktkategorie nicht löschen, da sie die folgenden Produkte enthält: :products. Diese Produkte sind mit bestehenden Bestellungen verknüpft. Bitte verschieben Sie :product_name in eine andere Kategorie, bevor Sie versuchen, diese zu löschen.", + "products": "Produkte", + "product": "Produkt", + "Product category :productCategoryId has been deleted.": "Produktkategorie :productCategoryId wurde gelöscht.", + "You cannot delete the last product category. Please create another category before deleting this one.": "Sie können die letzte Produktkategorie nicht löschen. Bitte erstellen Sie eine weitere Kategorie, bevor Sie diese löschen.", + "The product category with ID :id was not found.": "Die Produktkategorie mit der ID :id wurde nicht gefunden.", + "Expired": "Abgelaufen", + "Limit Reached": "Limit erreicht", + "Deleted": "Gelöscht", + "Active": "Aktiv", + "This ticket is invalid": "Dieses Ticket ist ungültig.", + "There are no tickets available. ' .\n 'If you would like to assign a product to this attendee,' .\n ' please adjust the product\\'s available quantity.": "Es sind keine Tickets verfügbar. Wenn Sie einem Teilnehmer ein Produkt zuweisen möchten, passen Sie bitte die verfügbare Menge des Produkts an.", + "The product price ID is invalid.": "Die Produktpreis-ID ist ungültig.", + "Product ID is not valid": "Produkt-ID ist ungültig.", + "There are no products available. If you would like to assign this product to this attendee, please adjust the product\\'s available quantity.": "Es sind keine Produkte verfügbar. Wenn Sie diesem Teilnehmer ein Produkt zuweisen möchten, passen Sie bitte die verfügbare Menge des Produkts an.", + "There is an unexpected product price ID in the order": "Es gibt eine unerwartete Produktpreis-ID in der Bestellung.", + "Product type cannot be changed as products have been registered for this type": "Der Produkttyp kann nicht geändert werden, da Produkte für diesen Typ registriert wurden.", + "The ordered category IDs must exactly match all categories for the event without missing or extra IDs.": "Die bestellten Kategorie-IDs müssen genau mit allen Kategorien der Veranstaltung übereinstimmen, ohne fehlende oder zusätzliche IDs.", + "The ordered product IDs must exactly match all products for the event without missing or extra IDs.": "Die bestellten Produkt-IDs müssen genau mit allen Produkten der Veranstaltung übereinstimmen, ohne fehlende oder zusätzliche IDs.", + "This product is outdated. Please reload the page.": "Dieses Produkt ist veraltet. Bitte laden Sie die Seite neu.", + "Reserved": "Reserviert", + "Cancelled": "Storniert", + "Completed": "Abgeschlossen", + "Awaiting offline payment": "Warten auf Offline-Zahlung", + "ID": "ID", + "First Name": "Vorname", + "Last Name": "Nachname", + "Email": "E-Mail", + "Status": "Status", + "Is Checked In": "Eingecheckt", + "Checked In At": "Eingecheckt am", + "Product ID": "Produkt-ID", + "Product Name": "Produktname", + "Event ID": "Veranstaltungs-ID", + "Public ID": "Öffentliche ID", + "Short ID": "Kurz-ID", + "Created Date": "Erstellungsdatum", + "Last Updated Date": "Letztes Aktualisierungsdatum", + "Notes": "Notizen", + "Total Before Additions": "Gesamtsumme vor Zuschlägen", + "Total Gross": "Gesamtbrutto", + "Total Tax": "Gesamtsteuer", + "Total Fee": "Gesamtgebühr", + "Total Refunded": "Gesamtbetrag erstattet", + "Payment Status": "Zahlungsstatus", + "Refund Status": "Erstattungsstatus", + "Currency": "Währung", + "Created At": "Erstellt am", + "Payment Gateway": "Zahlungsanbieter", + "Is Partially Refunded": "Teilweise erstattet", + "Is Fully Refunded": "Vollständig erstattet", + "Is Free Order": "Kostenlose Bestellung", + "Is Manually Created": "Manuell erstellt", + "Billing Address": "Rechnungsadresse", + "Promo Code": "Rabattcode", + "Failed to handle incoming Stripe webhook": "Fehler beim Verarbeiten des eingehenden Stripe-Webhooks", + "Notes must be less than 2000 characters": "Notizen dürfen maximal 2000 Zeichen enthalten", + "Invalid payment provider selected.": "Ungültiger Zahlungsanbieter ausgewählt.", + "Payment instructions are required when offline payments are enabled.": "Zahlungsanweisungen sind erforderlich, wenn Offline-Zahlungen aktiviert sind.", + "The invoice prefix may only contain letters, numbers, and hyphens.": "Das Rechnungspräfix darf nur Buchstaben, Zahlen und Bindestriche enthalten.", + "The organization name is required when invoicing is enabled.": "Der Organisationsname ist erforderlich, wenn Rechnungsstellung aktiviert ist.", + "The organization address is required when invoicing is enabled.": "Die Organisationsadresse ist erforderlich, wenn Rechnungsstellung aktiviert ist.", + "The invoice start number must be at least 1.": "Die Rechnungsstartnummer muss mindestens 1 sein.", + "There is no default account configuration available": "Es ist keine Standardkontokonfiguration verfügbar", + "Product price ID is not valid": "Produktpreis-ID ist ungültig", + "Invoice": "Rechnung", + "Editing order with ID: :id": "Bestellung mit ID bearbeiten: :id", + "Marking order as paid": "Bestellung als bezahlt markieren", + "Received a :event Stripe event, which has no handler": "Ein :event Stripe-Ereignis wurde empfangen, aber es gibt keinen Handler dafür", + "Order is not in the correct status to transition to offline payment": "Die Bestellung hat nicht den richtigen Status für den Wechsel zur Offline-Zahlung", + "Order reservation has expired": "Die Bestellreservierung ist abgelaufen", + "Offline payments are not enabled for this event": "Offline-Zahlungen sind für diese Veranstaltung nicht aktiviert", + "There are no products available in this category": "Es sind keine Produkte in dieser Kategorie verfügbar", + "Webhook not found": "Webhook nicht gefunden", + "Unable to check in as attendee :attendee_name\\'s order is awaiting payment": "Einchecken nicht möglich, da die Bestellung von :attendee_name auf Zahlung wartet", + "Attendee :attendee_name\\'s order cannot be marked as paid. Please check your event settings": "Die Bestellung von :attendee_name kann nicht als bezahlt markiert werden. Bitte überprüfen Sie Ihre Veranstaltungs-Einstellungen", + "Invoice already exists": "Rechnung existiert bereits", + "Invoice not found": "Rechnung nicht gefunden", + "Order is not awaiting offline payment": "Die Bestellung wartet nicht auf eine Offline-Zahlung", + "Refund already processed": "Rückerstattung bereits bearbeitet", + "Stripe refund successful": "Stripe-Rückerstattung erfolgreich", + "There are no tickets available for this event": "Für diese Veranstaltung sind keine Tickets verfügbar", + "Address line 1 is required": "Adresszeile 1 ist erforderlich", + "City is required": "Stadt ist erforderlich", + "Zip or postal code is required": "Postleitzahl ist erforderlich", + "Country is required": "Land ist erforderlich", + "If you did not request a password reset, please immediately reset your password.": "Falls Sie kein Passwort-Reset angefordert haben, setzen Sie Ihr Passwort bitte sofort zurück.", + "ℹ️ Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "ℹ️ Ihre Bestellung wartet auf Zahlung. Tickets wurden ausgestellt, sind jedoch erst nach Zahlung gültig.", + "ℹ️ This order is pending payment. Please mark the payment as received on the order management page once payment is received.": "ℹ️ Diese Bestellung wartet auf Zahlung. Bitte markieren Sie die Zahlung als eingegangen auf der Bestellverwaltungsseite, sobald die Zahlung erfolgt ist.", + "Order Status:": "Bestellstatus:", + "Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "Ihre Bestellung wartet auf Zahlung. Tickets wurden ausgestellt, sind jedoch erst nach Zahlung gültig.", + "Payment Instructions": "Zahlungsanweisungen", + "Please follow the instructions below to complete your payment.": "Bitte folgen Sie den untenstehenden Anweisungen, um Ihre Zahlung abzuschließen.", + "Invoice Number": "Rechnungsnummer", + "Date Issued": "Ausstellungsdatum", + "Due Date": "Fälligkeitsdatum", + "Amount Due": "Fälliger Betrag", + "Billed To": "Rechnung an", + "DESCRIPTION": "BESCHREIBUNG", + "RATE": "SATZ", + "QTY": "MENGE", + "AMOUNT": "BETRAG", + "Subtotal": "Zwischensumme", + "Total Discount": "Gesamtrabatt", + "Total Service Fee": "Gesamtservicegebühr", + "Total Amount": "Gesamtbetrag", + "For any queries, please contact us at": "Bei Fragen kontaktieren Sie uns bitte unter", + "Tax Information": "Steuerinformationen", + "The number of attendees does not match the number of tickets in the order": "Die Anzahl der Teilnehmer stimmt nicht mit der Anzahl der Tickets überein" +} diff --git a/backend/lang/es.json b/backend/lang/es.json index 216230a4d6..dea4844bde 100644 --- a/backend/lang/es.json +++ b/backend/lang/es.json @@ -283,5 +283,141 @@ "Attendee does not belong to this check-in list": "El asistente no pertenece a esta lista de registro", "Attendee :attendee_name\\'s ticket is cancelled": "La entrada del asistente :attendee_name ha sido cancelada", "Check-in list is not active yet": "La lista de registro aún no está activa", - "The number of attendees does not match the number of tickets in the order": "" -} \ No newline at end of file + "The number of attendees does not match the number of tickets in the order": "El número de asistentes no coincide con el número de entradas en el pedido.", + "Product is required": "El producto es obligatorio.", + "Product price is required": "El precio del producto es obligatorio.", + "Please select at least one product.": "Por favor, selecciona al menos un producto.", + "The sale start date must be after the product sale start date.": "La fecha de inicio de la venta debe ser posterior a la fecha de inicio de la venta del producto.", + "You must select a product category.": "Debes seleccionar una categoría de producto.", + "Invalid direction. Must be either asc or desc": "Dirección inválida. Debe ser asc o desc.", + "DomainObject must be a valid :interface.": "El objeto de dominio debe ser una :interface válida.", + "Nested relationships must be an array of Relationship objects.": "Las relaciones anidadas deben ser un array de objetos de relación.", + "OrderAndDirections must be an array of OrderAndDirection objects.": "OrderAndDirections debe ser un array de objetos OrderAndDirection.", + "Attendee :attendee_name\\'s product is cancelled": "El producto del asistente :attendee_name está cancelado.", + "Tickets": "Entradas", + "There are no tickets available for this event.": "No hay entradas disponibles para este evento.", + "You haven\\'t selected any products": "No has seleccionado ningún producto.", + "The maximum number of products available for :products is :max": "El número máximo de productos disponibles para :products es :max.", + "You must order at least :min products for :product": "Debes pedir al menos :min productos para :product.", + "The product :product is sold out": "El producto :product está agotado.", + "The maximum number of products available for :product is :max": "El número máximo de productos disponibles para :product es :max.", + "Sorry, these products are sold out": "Lo sentimos, estos productos están agotados.", + "The maximum number of products available is :max": "El número máximo de productos disponibles es :max.", + "Product with id :id not found": "Producto con id :id no encontrado.", + "You cannot delete this product because it has orders associated with it. You can hide it instead.": "No puedes eliminar este producto porque tiene pedidos asociados. Puedes ocultarlo en su lugar.", + "Invalid product ids: :ids": "IDs de productos inválidos: :ids.", + "Product is hidden without promo code": "El producto está oculto sin código promocional.", + "Product is sold out": "El producto está agotado.", + "Product is before sale start date": "El producto está antes de la fecha de inicio de la venta.", + "Product is after sale end date": "El producto está después de la fecha de finalización de la venta.", + "Product is hidden": "El producto está oculto.", + "Cannot delete product price with id :id because it has sales": "No se puede eliminar el precio del producto con id :id porque tiene ventas.", + "You cannot delete this product category because it contains the following products: :products. These products are linked to existing orders. Please move the :product_name to another category before attempting to delete this one.": "No puedes eliminar esta categoría de producto porque contiene los siguientes productos: :products. Estos productos están vinculados a pedidos existentes. Mueve el :product_name a otra categoría antes de intentar eliminar esta.", + "products": "productos", + "product": "producto", + "Product category :productCategoryId has been deleted.": "La categoría de producto :productCategoryId ha sido eliminada.", + "You cannot delete the last product category. Please create another category before deleting this one.": "No puedes eliminar la última categoría de producto. Por favor, crea otra categoría antes de eliminar esta.", + "The product category with ID :id was not found.": "No se encontró la categoría de producto con ID :id.", + "Expired": "Expirado", + "Limit Reached": "Límite alcanzado", + "Deleted": "Eliminado", + "Active": "Activo", + "This ticket is invalid": "Este ticket no es válido.", + "There are no tickets available. ' .\n 'If you would like to assign a product to this attendee,' .\n ' please adjust the product\\'s available quantity.": "No hay entradas disponibles. Si deseas asignar un producto a este asistente, ajusta la cantidad disponible del producto.", + "The product price ID is invalid.": "El ID de precio del producto no es válido.", + "Product ID is not valid": "El ID del producto no es válido.", + "There are no products available. If you would like to assign this product to this attendee, please adjust the product\\'s available quantity.": "No hay productos disponibles. Si deseas asignar este producto a este asistente, ajusta la cantidad disponible del producto.", + "There is an unexpected product price ID in the order": "Hay un ID de precio de producto inesperado en el pedido.", + "Product type cannot be changed as products have been registered for this type": "El tipo de producto no se puede cambiar porque se han registrado productos para este tipo.", + "The ordered category IDs must exactly match all categories for the event without missing or extra IDs.": "Los IDs de categoría ordenados deben coincidir exactamente con todas las categorías del evento sin faltar ni sobrar IDs.", + "The ordered product IDs must exactly match all products for the event without missing or extra IDs.": "Los IDs de producto ordenados deben coincidir exactamente con todos los productos del evento sin faltar ni sobrar IDs.", + "This product is outdated. Please reload the page.": "Este producto está desactualizado. Por favor, recarga la página.", + "Reserved": "Reservado", + "Cancelled": "Cancelado", + "Completed": "Completado", + "Awaiting offline payment": "En espera de pago offline", + "ID": "ID", + "First Name": "Nombre", + "Last Name": "Apellido", + "Email": "Correo electrónico", + "Status": "Estado", + "Is Checked In": "Registrado", + "Checked In At": "Registrado en", + "Product ID": "ID del producto", + "Product Name": "Nombre del producto", + "Event ID": "ID del evento", + "Public ID": "ID público", + "Short ID": "ID corto", + "Created Date": "Fecha de creación", + "Last Updated Date": "Fecha de última actualización", + "Notes": "Notas", + "Total Before Additions": "Total antes de adiciones", + "Total Gross": "Total bruto", + "Total Tax": "Total impuestos", + "Total Fee": "Total de tarifas", + "Total Refunded": "Total reembolsado", + "Payment Status": "Estado del pago", + "Refund Status": "Estado del reembolso", + "Currency": "Moneda", + "Created At": "Creado en", + "Payment Gateway": "Pasarela de pago", + "Is Partially Refunded": "Reembolsado parcialmente", + "Is Fully Refunded": "Reembolsado completamente", + "Is Free Order": "Pedido gratuito", + "Is Manually Created": "Creado manualmente", + "Billing Address": "Dirección de facturación", + "Promo Code": "Código promocional", + "Failed to handle incoming Stripe webhook": "No se pudo procesar el webhook entrante de Stripe", + "Notes must be less than 2000 characters": "Las notas deben tener menos de 2000 caracteres", + "Invalid payment provider selected.": "Proveedor de pago no válido seleccionado.", + "Payment instructions are required when offline payments are enabled.": "Se requieren instrucciones de pago cuando los pagos offline están habilitados.", + "The invoice prefix may only contain letters, numbers, and hyphens.": "El prefijo de la factura solo puede contener letras, números y guiones.", + "The organization name is required when invoicing is enabled.": "El nombre de la organización es obligatorio cuando la facturación está habilitada.", + "The organization address is required when invoicing is enabled.": "La dirección de la organización es obligatoria cuando la facturación está habilitada.", + "The invoice start number must be at least 1.": "El número inicial de la factura debe ser al menos 1.", + "There is no default account configuration available": "No hay una configuración de cuenta predeterminada disponible", + "Product price ID is not valid": "El ID del precio del producto no es válido", + "Invoice": "Factura", + "Editing order with ID: :id": "Editando pedido con ID: :id", + "Marking order as paid": "Marcando pedido como pagado", + "Received a :event Stripe event, which has no handler": "Se recibió un evento de Stripe :event, que no tiene un manejador", + "Order is not in the correct status to transition to offline payment": "El pedido no está en el estado correcto para pasar a pago offline", + "Order reservation has expired": "La reserva del pedido ha expirado", + "Offline payments are not enabled for this event": "Los pagos offline no están habilitados para este evento", + "There are no products available in this category": "No hay productos disponibles en esta categoría", + "Webhook not found": "Webhook no encontrado", + "Unable to check in as attendee :attendee_name\\'s order is awaiting payment": "No se puede registrar porque el pedido de :attendee_name está en espera de pago", + "Attendee :attendee_name\\'s order cannot be marked as paid. Please check your event settings": "El pedido de :attendee_name no se puede marcar como pagado. Por favor, revisa la configuración de tu evento", + "Invoice already exists": "La factura ya existe", + "Invoice not found": "Factura no encontrada", + "Order is not awaiting offline payment": "El pedido no está en espera de pago offline", + "Refund already processed": "Reembolso ya procesado", + "Stripe refund successful": "Reembolso de Stripe exitoso", + "There are no tickets available for this event": "No hay boletos disponibles para este evento", + "Address line 1 is required": "La dirección (línea 1) es obligatoria", + "City is required": "La ciudad es obligatoria", + "Zip or postal code is required": "El código postal es obligatorio", + "Country is required": "El país es obligatorio", + "If you did not request a password reset, please immediately reset your password.": "Si no solicitaste un restablecimiento de contraseña, por favor restablécela de inmediato.", + "ℹ️ Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "ℹ️ Tu pedido está pendiente de pago. Los boletos han sido emitidos, pero no serán válidos hasta que se reciba el pago.", + "ℹ️ This order is pending payment. Please mark the payment as received on the order management page once payment is received.": "ℹ️ Este pedido está pendiente de pago. Por favor, márcalo como pagado en la página de gestión de pedidos una vez recibido el pago.", + "Order Status:": "Estado del pedido:", + "Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "Tu pedido está pendiente de pago. Los boletos han sido emitidos, pero no serán válidos hasta que se reciba el pago.", + "Payment Instructions": "Instrucciones de pago", + "Please follow the instructions below to complete your payment.": "Por favor, sigue las instrucciones a continuación para completar tu pago.", + "Invoice Number": "Número de factura", + "Date Issued": "Fecha de emisión", + "Due Date": "Fecha de vencimiento", + "Amount Due": "Monto adeudado", + "Billed To": "Facturado a", + "DESCRIPTION": "DESCRIPCIÓN", + "RATE": "TARIFA", + "QTY": "CANTIDAD", + "AMOUNT": "IMPORTE", + "Subtotal": "Subtotal", + "Total Discount": "Descuento total", + "Total Service Fee": "Tarifa total del servicio", + "Total Amount": "Monto total", + "For any queries, please contact us at": "Para cualquier consulta, por favor contáctanos en", + "Tax Information": "Información fiscal" +} diff --git a/backend/lang/fr.json b/backend/lang/fr.json index f320c53bcf..1f6023a0c5 100644 --- a/backend/lang/fr.json +++ b/backend/lang/fr.json @@ -283,5 +283,141 @@ "Attendee does not belong to this check-in list": "Le participant n'appartient pas à cette liste de pointage", "Attendee :attendee_name\\'s ticket is cancelled": "Le billet de l'participant :attendee_name a été annulé", "Check-in list is not active yet": "La liste d'enregistrement n'est pas encore active", - "The number of attendees does not match the number of tickets in the order": "" -} \ No newline at end of file + "The number of attendees does not match the number of tickets in the order": "Le nombre de participants ne correspond pas au nombre de billets dans la commande.", + "Product is required": "Le produit est requis.", + "Product price is required": "Le prix du produit est requis.", + "Please select at least one product.": "Veuillez sélectionner au moins un produit.", + "The sale start date must be after the product sale start date.": "La date de début de vente doit être après la date de début de vente du produit.", + "You must select a product category.": "Vous devez sélectionner une catégorie de produit.", + "Invalid direction. Must be either asc or desc": "Direction invalide. Doit être soit asc soit desc.", + "DomainObject must be a valid :interface.": "L'objet domaine doit être une :interface valide.", + "Nested relationships must be an array of Relationship objects.": "Les relations imbriquées doivent être un tableau d'objets Relationship.", + "OrderAndDirections must be an array of OrderAndDirection objects.": "OrderAndDirections doit être un tableau d'objets OrderAndDirection.", + "Attendee :attendee_name\\'s product is cancelled": "Le produit du participant :attendee_name est annulé.", + "Tickets": "Billets", + "There are no tickets available for this event.": "Il n'y a pas de billets disponibles pour cet événement.", + "You haven\\'t selected any products": "Vous n'avez sélectionné aucun produit.", + "The maximum number of products available for :products is :max": "Le nombre maximum de produits disponibles pour :products est :max.", + "You must order at least :min products for :product": "Vous devez commander au moins :min produits pour :product.", + "The product :product is sold out": "Le produit :product est épuisé.", + "The maximum number of products available for :product is :max": "Le nombre maximum de produits disponibles pour :product est :max.", + "Sorry, these products are sold out": "Désolé, ces produits sont épuisés.", + "The maximum number of products available is :max": "Le nombre maximum de produits disponibles est :max.", + "Product with id :id not found": "Produit avec id :id introuvable.", + "You cannot delete this product because it has orders associated with it. You can hide it instead.": "Vous ne pouvez pas supprimer ce produit car il a des commandes associées. Vous pouvez le masquer à la place.", + "Invalid product ids: :ids": "Identifiants de produits invalides : :ids.", + "Product is hidden without promo code": "Le produit est masqué sans code promo.", + "Product is sold out": "Le produit est épuisé.", + "Product is before sale start date": "Le produit est avant la date de début de la vente.", + "Product is after sale end date": "Le produit est après la date de fin de la vente.", + "Product is hidden": "Le produit est masqué.", + "Cannot delete product price with id :id because it has sales": "Impossible de supprimer le prix du produit avec id :id car il a des ventes.", + "You cannot delete this product category because it contains the following products: :products. These products are linked to existing orders. Please move the :product_name to another category before attempting to delete this one.": "Vous ne pouvez pas supprimer cette catégorie de produit car elle contient les produits suivants : :products. Ces produits sont liés à des commandes existantes. Veuillez déplacer le :product_name vers une autre catégorie avant d'essayer de supprimer celle-ci.", + "products": "produits", + "product": "produit", + "Product category :productCategoryId has been deleted.": "La catégorie de produit :productCategoryId a été supprimée.", + "You cannot delete the last product category. Please create another category before deleting this one.": "Vous ne pouvez pas supprimer la dernière catégorie de produit. Veuillez en créer une autre avant de supprimer celle-ci.", + "The product category with ID :id was not found.": "La catégorie de produit avec ID :id n'a pas été trouvée.", + "Expired": "Expiré", + "Limit Reached": "Limite atteinte", + "Deleted": "Supprimé", + "Active": "Actif", + "This ticket is invalid": "Ce billet n'est pas valide.", + "There are no tickets available. ' .\n 'If you would like to assign a product to this attendee,' .\n ' please adjust the product\\'s available quantity.": "Il n'y a pas de billets disponibles. Si vous souhaitez attribuer un produit à ce participant, ajustez la quantité disponible du produit.", + "The product price ID is invalid.": "L'ID du prix du produit n'est pas valide.", + "Product ID is not valid": "L'ID du produit n'est pas valide.", + "There are no products available. If you would like to assign this product to this attendee, please adjust the product\\'s available quantity.": "Il n'y a pas de produits disponibles. Si vous souhaitez attribuer ce produit à ce participant, ajustez la quantité disponible du produit.", + "There is an unexpected product price ID in the order": "Il y a un ID de prix de produit inattendu dans la commande.", + "Product type cannot be changed as products have been registered for this type": "Le type de produit ne peut pas être modifié car des produits ont été enregistrés pour ce type.", + "The ordered category IDs must exactly match all categories for the event without missing or extra IDs.": "Les ID de catégorie commandés doivent correspondre exactement à toutes les catégories de l'événement sans ID manquants ou en trop.", + "The ordered product IDs must exactly match all products for the event without missing or extra IDs.": "Les ID de produit commandés doivent correspondre exactement à tous les produits de l'événement sans ID manquants ou en trop.", + "This product is outdated. Please reload the page.": "Ce produit est obsolète. Veuillez recharger la page.", + "Reserved": "Réservé", + "Cancelled": "Annulé", + "Completed": "Terminé", + "Awaiting offline payment": "En attente de paiement hors ligne", + "ID": "ID", + "First Name": "Prénom", + "Last Name": "Nom", + "Email": "E-mail", + "Status": "Statut", + "Is Checked In": "Enregistré", + "Checked In At": "Enregistré à", + "Product ID": "ID du produit", + "Product Name": "Nom du produit", + "Event ID": "ID de l'événement", + "Public ID": "ID public", + "Short ID": "ID court", + "Created Date": "Date de création", + "Last Updated Date": "Date de dernière mise à jour", + "Notes": "Notes", + "Total Before Additions": "Total avant ajouts", + "Total Gross": "Total brut", + "Total Tax": "Total des taxes", + "Total Fee": "Total des frais", + "Total Refunded": "Total remboursé", + "Payment Status": "Statut du paiement", + "Refund Status": "Statut du remboursement", + "Currency": "Devise", + "Created At": "Créé le", + "Payment Gateway": "Passerelle de paiement", + "Is Partially Refunded": "Partiellement remboursé", + "Is Fully Refunded": "Entièrement remboursé", + "Is Free Order": "Commande gratuite", + "Is Manually Created": "Créé manuellement", + "Billing Address": "Adresse de facturation", + "Promo Code": "Code promo", + "Failed to handle incoming Stripe webhook": "Échec du traitement du webhook entrant Stripe", + "Notes must be less than 2000 characters": "Les notes doivent contenir moins de 2000 caractères", + "Invalid payment provider selected.": "Fournisseur de paiement invalide sélectionné.", + "Payment instructions are required when offline payments are enabled.": "Les instructions de paiement sont requises lorsque les paiements hors ligne sont activés.", + "The invoice prefix may only contain letters, numbers, and hyphens.": "Le préfixe de la facture ne peut contenir que des lettres, des chiffres et des tirets.", + "The organization name is required when invoicing is enabled.": "Le nom de l'organisation est requis lorsque la facturation est activée.", + "The organization address is required when invoicing is enabled.": "L'adresse de l'organisation est requise lorsque la facturation est activée.", + "The invoice start number must be at least 1.": "Le numéro de début de facture doit être au moins 1.", + "There is no default account configuration available": "Aucune configuration de compte par défaut disponible", + "Product price ID is not valid": "L'ID du prix du produit n'est pas valide", + "Invoice": "Facture", + "Editing order with ID: :id": "Modification de la commande avec l'ID : :id", + "Marking order as paid": "Marquage de la commande comme payée", + "Received a :event Stripe event, which has no handler": "Un événement Stripe :event a été reçu, mais aucun gestionnaire n'est défini", + "Order is not in the correct status to transition to offline payment": "La commande n'est pas dans le bon état pour passer au paiement hors ligne", + "Order reservation has expired": "La réservation de la commande a expiré", + "Offline payments are not enabled for this event": "Les paiements hors ligne ne sont pas activés pour cet événement", + "There are no products available in this category": "Aucun produit disponible dans cette catégorie", + "Webhook not found": "Webhook introuvable", + "Unable to check in as attendee :attendee_name\\'s order is awaiting payment": "Impossible d'enregistrer car la commande de :attendee_name est en attente de paiement", + "Attendee :attendee_name\\'s order cannot be marked as paid. Please check your event settings": "La commande de :attendee_name ne peut pas être marquée comme payée. Veuillez vérifier les paramètres de votre événement", + "Invoice already exists": "La facture existe déjà", + "Invoice not found": "Facture introuvable", + "Order is not awaiting offline payment": "La commande n'est pas en attente de paiement hors ligne", + "Refund already processed": "Remboursement déjà effectué", + "Stripe refund successful": "Remboursement Stripe réussi", + "There are no tickets available for this event": "Aucun billet disponible pour cet événement", + "Address line 1 is required": "Ligne d'adresse 1 requise", + "City is required": "Ville requise", + "Zip or postal code is required": "Code postal requis", + "Country is required": "Pays requis", + "If you did not request a password reset, please immediately reset your password.": "Si vous n'avez pas demandé de réinitialisation de mot de passe, veuillez le réinitialiser immédiatement.", + "ℹ️ Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "ℹ️ Votre commande est en attente de paiement. Les billets ont été émis, mais ne seront valides qu'après réception du paiement.", + "ℹ️ This order is pending payment. Please mark the payment as received on the order management page once payment is received.": "ℹ️ Cette commande est en attente de paiement. Veuillez la marquer comme payée sur la page de gestion des commandes une fois le paiement reçu.", + "Order Status:": "Statut de la commande :", + "Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "Votre commande est en attente de paiement. Les billets ont été émis, mais ne seront valides qu'après réception du paiement.", + "Payment Instructions": "Instructions de paiement", + "Please follow the instructions below to complete your payment.": "Veuillez suivre les instructions ci-dessous pour finaliser votre paiement.", + "Invoice Number": "Numéro de facture", + "Date Issued": "Date d'émission", + "Due Date": "Date d'échéance", + "Amount Due": "Montant dû", + "Billed To": "Facturé à", + "DESCRIPTION": "DESCRIPTION", + "RATE": "TAUX", + "QTY": "QTÉ", + "AMOUNT": "MONTANT", + "Subtotal": "Sous-total", + "Total Discount": "Remise totale", + "Total Service Fee": "Frais de service totaux", + "Total Amount": "Montant total", + "For any queries, please contact us at": "Pour toute question, veuillez nous contacter à", + "Tax Information": "Informations fiscales" +} diff --git a/backend/lang/pt-br.json b/backend/lang/pt-br.json index 5548c20263..3b85c0c4c9 100644 --- a/backend/lang/pt-br.json +++ b/backend/lang/pt-br.json @@ -283,5 +283,141 @@ "Attendee does not belong to this check-in list": "O participante não pertence a esta lista de check-in", "Attendee :attendee_name\\'s ticket is cancelled": "O ingresso do participante :attendee_name foi cancelado", "Check-in list is not active yet": "A lista de check-in ainda não está ativa", - "The number of attendees does not match the number of tickets in the order": "" -} \ No newline at end of file + "The number of attendees does not match the number of tickets in the order": "O número de participantes não coincide com o número de ingressos no pedido.", + "Product is required": "O produto é obrigatório.", + "Product price is required": "O preço do produto é obrigatório.", + "Please select at least one product.": "Por favor, selecione ao menos um produto.", + "The sale start date must be after the product sale start date.": "A data de início da venda deve ser posterior à data de início da venda do produto.", + "You must select a product category.": "Você deve selecionar uma categoria de produto.", + "Invalid direction. Must be either asc or desc": "Direção inválida. Deve ser asc ou desc.", + "DomainObject must be a valid :interface.": "O objeto de domínio deve ser um :interface válido.", + "Nested relationships must be an array of Relationship objects.": "As relações aninhadas devem ser uma matriz de objetos Relationship.", + "OrderAndDirections must be an array of OrderAndDirection objects.": "OrderAndDirections deve ser uma matriz de objetos OrderAndDirection.", + "Attendee :attendee_name\\'s product is cancelled": "O produto do participante :attendee_name foi cancelado.", + "Tickets": "Ingressos", + "There are no tickets available for this event.": "Não há ingressos disponíveis para este evento.", + "You haven\\'t selected any products": "Você não selecionou nenhum produto.", + "The maximum number of products available for :products is :max": "O número máximo de produtos disponíveis para :products é :max.", + "You must order at least :min products for :product": "Você deve solicitar pelo menos :min produtos para :product.", + "The product :product is sold out": "O produto :product está esgotado.", + "The maximum number of products available for :product is :max": "O número máximo de produtos disponíveis para :product é :max.", + "Sorry, these products are sold out": "Desculpe, esses produtos estão esgotados.", + "The maximum number of products available is :max": "O número máximo de produtos disponíveis é :max.", + "Product with id :id not found": "Produto com id :id não encontrado.", + "You cannot delete this product because it has orders associated with it. You can hide it instead.": "Você não pode excluir este produto porque ele possui pedidos associados. Você pode ocultá-lo.", + "Invalid product ids: :ids": "IDs de produto inválidos: :ids.", + "Product is hidden without promo code": "O produto está oculto sem código promocional.", + "Product is sold out": "O produto está esgotado.", + "Product is before sale start date": "O produto está antes da data de início da venda.", + "Product is after sale end date": "O produto está após a data de término da venda.", + "Product is hidden": "O produto está oculto.", + "Cannot delete product price with id :id because it has sales": "Não é possível excluir o preço do produto com id :id porque ele possui vendas.", + "You cannot delete this product category because it contains the following products: :products. These products are linked to existing orders. Please move the :product_name to another category before attempting to delete this one.": "Você não pode excluir esta categoria de produto porque contém os seguintes produtos: :products. Esses produtos estão vinculados a pedidos existentes. Por favor, mova o :product_name para outra categoria antes de tentar excluir esta.", + "products": "produtos", + "product": "produto", + "Product category :productCategoryId has been deleted.": "A categoria de produto :productCategoryId foi excluída.", + "You cannot delete the last product category. Please create another category before deleting this one.": "Você não pode excluir a última categoria de produto. Por favor, crie uma nova categoria antes de excluir esta.", + "The product category with ID :id was not found.": "A categoria de produto com ID :id não foi encontrada.", + "Expired": "Expirado", + "Limit Reached": "Limite alcançado", + "Deleted": "Deletado", + "Active": "Ativo", + "This ticket is invalid": "Este ingresso não é válido.", + "There are no tickets available. ' .\n 'If you would like to assign a product to this attendee,' .\n ' please adjust the product\\'s available quantity.": "Não há ingressos disponíveis. Caso queira atribuir um produto para este participante, ajuste a quantidade disponível do produto.", + "The product price ID is invalid.": "O ID do preço do produto é inválido.", + "Product ID is not valid": "O ID do produto não é válido.", + "There are no products available. If you would like to assign this product to this attendee, please adjust the product\\'s available quantity.": "Não há produtos disponíveis. Caso queira atribuir este produto a este participante, ajuste a quantidade disponível do produto.", + "There is an unexpected product price ID in the order": "Há um ID de preço de produto inesperado no pedido.", + "Product type cannot be changed as products have been registered for this type": "O tipo de produto não pode ser alterado pois há produtos registrados para este tipo.", + "The ordered category IDs must exactly match all categories for the event without missing or extra IDs.": "Os IDs das categorias solicitadas devem corresponder exatamente a todas as categorias do evento, sem IDs faltando ou extras.", + "The ordered product IDs must exactly match all products for the event without missing or extra IDs.": "Os IDs dos produtos solicitados devem corresponder exatamente a todos os produtos do evento, sem IDs faltando ou extras.", + "This product is outdated. Please reload the page.": "Este produto está desatualizado. Por favor, recarregue a página.", + "Reserved": "Reservado", + "Cancelled": "Cancelado", + "Completed": "Concluído", + "Awaiting offline payment": "Aguardando pagamento offline", + "ID": "ID", + "First Name": "Nome", + "Last Name": "Sobrenome", + "Email": "E-mail", + "Status": "Status", + "Is Checked In": "Check-in realizado", + "Checked In At": "Check-in em", + "Product ID": "ID do produto", + "Product Name": "Nome do produto", + "Event ID": "ID do evento", + "Public ID": "ID público", + "Short ID": "ID curto", + "Created Date": "Data de criação", + "Last Updated Date": "Última atualização", + "Notes": "Notas", + "Total Before Additions": "Total antes dos adicionais", + "Total Gross": "Total bruto", + "Total Tax": "Total de impostos", + "Total Fee": "Total de taxas", + "Total Refunded": "Total reembolsado", + "Payment Status": "Status do pagamento", + "Refund Status": "Status do reembolso", + "Currency": "Moeda", + "Created At": "Criado em", + "Payment Gateway": "Gateway de pagamento", + "Is Partially Refunded": "Parcialmente reembolsado", + "Is Fully Refunded": "Totalmente reembolsado", + "Is Free Order": "Pedido gratuito", + "Is Manually Created": "Criado manualmente", + "Billing Address": "Endereço de cobrança", + "Promo Code": "Código promocional", + "Failed to handle incoming Stripe webhook": "Falha ao processar o webhook de entrada do Stripe", + "Notes must be less than 2000 characters": "As notas devem ter menos de 2000 caracteres", + "Invalid payment provider selected.": "Provedor de pagamento inválido selecionado.", + "Payment instructions are required when offline payments are enabled.": "As instruções de pagamento são obrigatórias quando pagamentos offline estão ativados.", + "The invoice prefix may only contain letters, numbers, and hyphens.": "O prefixo da fatura pode conter apenas letras, números e hífens.", + "The organization name is required when invoicing is enabled.": "O nome da organização é obrigatório quando a faturação está ativada.", + "The organization address is required when invoicing is enabled.": "O endereço da organização é obrigatório quando a faturação está ativada.", + "The invoice start number must be at least 1.": "O número inicial da fatura deve ser pelo menos 1.", + "There is no default account configuration available": "Não há configuração de conta padrão disponível", + "Product price ID is not valid": "O ID do preço do produto não é válido", + "Invoice": "Fatura", + "Editing order with ID: :id": "Editando pedido com ID: :id", + "Marking order as paid": "Marcando pedido como pago", + "Received a :event Stripe event, which has no handler": "Recebeu um evento Stripe :event, que não tem um manipulador", + "Order is not in the correct status to transition to offline payment": "O pedido não está no status correto para transição para pagamento offline", + "Order reservation has expired": "A reserva do pedido expirou", + "Offline payments are not enabled for this event": "Pagamentos offline não estão ativados para este evento", + "There are no products available in this category": "Não há produtos disponíveis nesta categoria", + "Webhook not found": "Webhook não encontrado", + "Unable to check in as attendee :attendee_name\\'s order is awaiting payment": "Não foi possível realizar o check-in, pois o pedido de :attendee_name está aguardando pagamento", + "Attendee :attendee_name\\'s order cannot be marked as paid. Please check your event settings": "O pedido de :attendee_name não pode ser marcado como pago. Por favor, verifique as configurações do evento", + "Invoice already exists": "A fatura já existe", + "Invoice not found": "Fatura não encontrada", + "Order is not awaiting offline payment": "O pedido não está aguardando pagamento offline", + "Refund already processed": "Reembolso já processado", + "Stripe refund successful": "Reembolso do Stripe bem-sucedido", + "There are no tickets available for this event": "Não há ingressos disponíveis para este evento", + "Address line 1 is required": "O endereço (linha 1) é obrigatório", + "City is required": "A cidade é obrigatória", + "Zip or postal code is required": "O CEP ou código postal é obrigatório", + "Country is required": "O país é obrigatório", + "If you did not request a password reset, please immediately reset your password.": "Se você não solicitou a redefinição de senha, redefina sua senha imediatamente.", + "ℹ️ Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "ℹ️ Seu pedido está pendente de pagamento. Os ingressos foram emitidos, mas não serão válidos até que o pagamento seja recebido.", + "ℹ️ This order is pending payment. Please mark the payment as received on the order management page once payment is received.": "ℹ️ Este pedido está pendente de pagamento. Por favor, marque o pagamento como recebido na página de gerenciamento de pedidos assim que for recebido.", + "Order Status:": "Status do pedido:", + "Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "Seu pedido está pendente de pagamento. Os ingressos foram emitidos, mas não serão válidos até que o pagamento seja recebido.", + "Payment Instructions": "Instruções de pagamento", + "Please follow the instructions below to complete your payment.": "Por favor, siga as instruções abaixo para concluir seu pagamento.", + "Invoice Number": "Número da fatura", + "Date Issued": "Data de emissão", + "Due Date": "Data de vencimento", + "Amount Due": "Valor devido", + "Billed To": "Faturado para", + "DESCRIPTION": "DESCRIÇÃO", + "RATE": "TARIFA", + "QTY": "QTD", + "AMOUNT": "VALOR", + "Subtotal": "Subtotal", + "Total Discount": "Desconto total", + "Total Service Fee": "Taxa total de serviço", + "Total Amount": "Valor total", + "For any queries, please contact us at": "Para qualquer dúvida, entre em contato conosco em", + "Tax Information": "Informações fiscais" +} diff --git a/backend/lang/pt.json b/backend/lang/pt.json index 7ed11c10a3..045ae8a79b 100644 --- a/backend/lang/pt.json +++ b/backend/lang/pt.json @@ -283,5 +283,141 @@ "Attendee does not belong to this check-in list": "O participante não pertence a esta lista de check-in", "Attendee :attendee_name\\'s ticket is cancelled": "O ingresso do participante :attendee_name foi cancelado", "Check-in list is not active yet": "A lista de check-in ainda não está ativa", - "The number of attendees does not match the number of tickets in the order": "" -} \ No newline at end of file + "The number of attendees does not match the number of tickets in the order": "O número de participantes não corresponde ao número de ingressos no pedido.", + "Product is required": "O produto é obrigatório.", + "Product price is required": "O preço do produto é obrigatório.", + "Please select at least one product.": "Por favor, selecione pelo menos um produto.", + "The sale start date must be after the product sale start date.": "A data de início da venda deve ser após a data de início da venda do produto.", + "You must select a product category.": "Você deve selecionar uma categoria de produto.", + "Invalid direction. Must be either asc or desc": "Direção inválida. Deve ser asc ou desc.", + "DomainObject must be a valid :interface.": "O objeto de domínio deve ser uma :interface válida.", + "Nested relationships must be an array of Relationship objects.": "As relações aninhadas devem ser um array de objetos Relationship.", + "OrderAndDirections must be an array of OrderAndDirection objects.": "OrderAndDirections deve ser um array de objetos OrderAndDirection.", + "Attendee :attendee_name\\'s product is cancelled": "O produto do participante :attendee_name está cancelado.", + "Tickets": "Ingressos", + "There are no tickets available for this event.": "Não há ingressos disponíveis para este evento.", + "You haven\\'t selected any products": "Você não selecionou nenhum produto.", + "The maximum number of products available for :products is :max": "O número máximo de produtos disponíveis para :products é :max.", + "You must order at least :min products for :product": "Você deve pedir pelo menos :min produtos para :product.", + "The product :product is sold out": "O produto :product está esgotado.", + "The maximum number of products available for :product is :max": "O número máximo de produtos disponíveis para :product é :max.", + "Sorry, these products are sold out": "Desculpe, esses produtos estão esgotados.", + "The maximum number of products available is :max": "O número máximo de produtos disponíveis é :max.", + "Product with id :id not found": "Produto com id :id não encontrado.", + "You cannot delete this product because it has orders associated with it. You can hide it instead.": "Você não pode excluir este produto porque ele tem pedidos associados. Você pode ocultá-lo.", + "Invalid product ids: :ids": "IDs de produto inválidos: :ids.", + "Product is hidden without promo code": "O produto está oculto sem código promocional.", + "Product is sold out": "O produto está esgotado.", + "Product is before sale start date": "O produto está antes da data de início da venda.", + "Product is after sale end date": "O produto está após a data de término da venda.", + "Product is hidden": "O produto está oculto.", + "Cannot delete product price with id :id because it has sales": "Não é possível excluir o preço do produto com id :id porque ele tem vendas.", + "You cannot delete this product category because it contains the following products: :products. These products are linked to existing orders. Please move the :product_name to another category before attempting to delete this one.": "Você não pode excluir esta categoria de produto porque ela contém os seguintes produtos: :products. Esses produtos estão vinculados a pedidos existentes. Por favor, mova o :product_name para outra categoria antes de tentar excluir esta.", + "products": "produtos", + "product": "produto", + "Product category :productCategoryId has been deleted.": "A categoria de produto :productCategoryId foi excluída.", + "You cannot delete the last product category. Please create another category before deleting this one.": "Você não pode excluir a última categoria de produto. Por favor, crie outra categoria antes de excluir esta.", + "The product category with ID :id was not found.": "A categoria de produto com ID :id não foi encontrada.", + "Expired": "Expirado", + "Limit Reached": "Limite atingido", + "Deleted": "Excluído", + "Active": "Ativo", + "This ticket is invalid": "Este ingresso não é válido.", + "There are no tickets available. ' .\n 'If you would like to assign a product to this attendee,' .\n ' please adjust the product\\'s available quantity.": "Não há ingressos disponíveis. Se você deseja atribuir um produto a este participante, ajuste a quantidade disponível do produto.", + "The product price ID is invalid.": "O ID do preço do produto não é válido.", + "Product ID is not valid": "O ID do produto não é válido.", + "There are no products available. If you would like to assign this product to this attendee, please adjust the product\\'s available quantity.": "Não há produtos disponíveis. Se você deseja atribuir este produto a este participante, ajuste a quantidade disponível do produto.", + "There is an unexpected product price ID in the order": "Há um ID de preço de produto inesperado no pedido.", + "Product type cannot be changed as products have been registered for this type": "O tipo de produto não pode ser alterado porque os produtos foram registrados para este tipo.", + "The ordered category IDs must exactly match all categories for the event without missing or extra IDs.": "Os IDs de categoria solicitados devem corresponder exatamente a todas as categorias do evento sem IDs faltando ou extras.", + "The ordered product IDs must exactly match all products for the event without missing or extra IDs.": "Os IDs de produtos solicitados devem corresponder exatamente a todos os produtos do evento sem IDs faltando ou extras.", + "This product is outdated. Please reload the page.": "Este produto está desatualizado. Por favor, recarregue a página.", + "Reserved": "Reservado", + "Cancelled": "Cancelado", + "Completed": "Concluído", + "Awaiting offline payment": "Aguardando pagamento offline", + "ID": "ID", + "First Name": "Nome", + "Last Name": "Sobrenome", + "Email": "E-mail", + "Status": "Status", + "Is Checked In": "Check-in realizado", + "Checked In At": "Check-in em", + "Product ID": "ID do produto", + "Product Name": "Nome do produto", + "Event ID": "ID do evento", + "Public ID": "ID público", + "Short ID": "ID curto", + "Created Date": "Data de criação", + "Last Updated Date": "Última atualização", + "Notes": "Notas", + "Total Before Additions": "Total antes dos adicionais", + "Total Gross": "Total bruto", + "Total Tax": "Total de impostos", + "Total Fee": "Total de taxas", + "Total Refunded": "Total reembolsado", + "Payment Status": "Status do pagamento", + "Refund Status": "Status do reembolso", + "Currency": "Moeda", + "Created At": "Criado em", + "Payment Gateway": "Gateway de pagamento", + "Is Partially Refunded": "Parcialmente reembolsado", + "Is Fully Refunded": "Totalmente reembolsado", + "Is Free Order": "Pedido gratuito", + "Is Manually Created": "Criado manualmente", + "Billing Address": "Endereço de cobrança", + "Promo Code": "Código promocional", + "Failed to handle incoming Stripe webhook": "Falha ao processar o webhook de entrada do Stripe", + "Notes must be less than 2000 characters": "As notas devem ter menos de 2000 caracteres", + "Invalid payment provider selected.": "Provedor de pagamento inválido selecionado.", + "Payment instructions are required when offline payments are enabled.": "As instruções de pagamento são obrigatórias quando pagamentos offline estão ativados.", + "The invoice prefix may only contain letters, numbers, and hyphens.": "O prefixo da fatura pode conter apenas letras, números e hífens.", + "The organization name is required when invoicing is enabled.": "O nome da organização é obrigatório quando a faturação está ativada.", + "The organization address is required when invoicing is enabled.": "O endereço da organização é obrigatório quando a faturação está ativada.", + "The invoice start number must be at least 1.": "O número inicial da fatura deve ser pelo menos 1.", + "There is no default account configuration available": "Não há configuração de conta padrão disponível", + "Product price ID is not valid": "O ID do preço do produto não é válido", + "Invoice": "Fatura", + "Editing order with ID: :id": "Editando pedido com ID: :id", + "Marking order as paid": "Marcando pedido como pago", + "Received a :event Stripe event, which has no handler": "Recebeu um evento Stripe :event, que não tem um manipulador", + "Order is not in the correct status to transition to offline payment": "O pedido não está no status correto para transição para pagamento offline", + "Order reservation has expired": "A reserva do pedido expirou", + "Offline payments are not enabled for this event": "Pagamentos offline não estão ativados para este evento", + "There are no products available in this category": "Não há produtos disponíveis nesta categoria", + "Webhook not found": "Webhook não encontrado", + "Unable to check in as attendee :attendee_name\\'s order is awaiting payment": "Não foi possível realizar o check-in, pois o pedido de :attendee_name está aguardando pagamento", + "Attendee :attendee_name\\'s order cannot be marked as paid. Please check your event settings": "O pedido de :attendee_name não pode ser marcado como pago. Por favor, verifique as configurações do evento", + "Invoice already exists": "A fatura já existe", + "Invoice not found": "Fatura não encontrada", + "Order is not awaiting offline payment": "O pedido não está aguardando pagamento offline", + "Refund already processed": "Reembolso já processado", + "Stripe refund successful": "Reembolso do Stripe bem-sucedido", + "There are no tickets available for this event": "Não há ingressos disponíveis para este evento", + "Address line 1 is required": "O endereço (linha 1) é obrigatório", + "City is required": "A cidade é obrigatória", + "Zip or postal code is required": "O CEP ou código postal é obrigatório", + "Country is required": "O país é obrigatório", + "If you did not request a password reset, please immediately reset your password.": "Se você não solicitou a redefinição de senha, redefina sua senha imediatamente.", + "ℹ️ Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "ℹ️ Seu pedido está pendente de pagamento. Os ingressos foram emitidos, mas não serão válidos até que o pagamento seja recebido.", + "ℹ️ This order is pending payment. Please mark the payment as received on the order management page once payment is received.": "ℹ️ Este pedido está pendente de pagamento. Por favor, marque o pagamento como recebido na página de gerenciamento de pedidos assim que for recebido.", + "Order Status:": "Status do pedido:", + "Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "Seu pedido está pendente de pagamento. Os ingressos foram emitidos, mas não serão válidos até que o pagamento seja recebido.", + "Payment Instructions": "Instruções de pagamento", + "Please follow the instructions below to complete your payment.": "Por favor, siga as instruções abaixo para concluir seu pagamento.", + "Invoice Number": "Número da fatura", + "Date Issued": "Data de emissão", + "Due Date": "Data de vencimento", + "Amount Due": "Valor devido", + "Billed To": "Faturado para", + "DESCRIPTION": "DESCRIÇÃO", + "RATE": "TARIFA", + "QTY": "QTD", + "AMOUNT": "VALOR", + "Subtotal": "Subtotal", + "Total Discount": "Desconto total", + "Total Service Fee": "Taxa total de serviço", + "Total Amount": "Valor total", + "For any queries, please contact us at": "Para qualquer dúvida, entre em contato conosco em", + "Tax Information": "Informações fiscais" +} diff --git a/backend/lang/ru.json b/backend/lang/ru.json index d91adc07d2..816f7ffe9f 100644 --- a/backend/lang/ru.json +++ b/backend/lang/ru.json @@ -298,5 +298,141 @@ "Attendee does not belong to this check-in list": "", "Attendee :attendee_name\\'s ticket is cancelled": "", "Check-in list is not active yet": "", - "The number of attendees does not match the number of tickets in the order": "" + "The number of attendees does not match the number of tickets in the order": "", + "Product is required": "", + "Product price is required": "", + "Please select at least one product.": "", + "The sale start date must be after the product sale start date.": "", + "You must select a product category.": "", + "Invalid direction. Must be either asc or desc": "", + "DomainObject must be a valid :interface.": "", + "Nested relationships must be an array of Relationship objects.": "", + "OrderAndDirections must be an array of OrderAndDirection objects.": "", + "Attendee :attendee_name\\'s product is cancelled": "", + "Tickets": "", + "There are no tickets available for this event.": "", + "You haven\\'t selected any products": "", + "The maximum number of products available for :products is :max": "", + "You must order at least :min products for :product": "", + "The product :product is sold out": "", + "The maximum number of products available for :product is :max": "", + "Sorry, these products are sold out": "", + "The maximum number of products available is :max": "", + "Product with id :id not found": "", + "You cannot delete this product because it has orders associated with it. You can hide it instead.": "", + "Invalid product ids: :ids": "", + "Product is hidden without promo code": "", + "Product is sold out": "", + "Product is before sale start date": "", + "Product is after sale end date": "", + "Product is hidden": "", + "Cannot delete product price with id :id because it has sales": "", + "You cannot delete this product category because it contains the following products: :products. These products are linked to existing orders. Please move the :product_name to another category before attempting to delete this one.": "", + "products": "", + "product": "", + "Product category :productCategoryId has been deleted.": "", + "You cannot delete the last product category. Please create another category before deleting this one.": "", + "The product category with ID :id was not found.": "", + "Expired": "", + "Limit Reached": "", + "Deleted": "", + "Active": "", + "This ticket is invalid": "", + "There are no tickets available. ' .\n 'If you would like to assign a product to this attendee,' .\n ' please adjust the product\\'s available quantity.": "", + "The product price ID is invalid.": "", + "Product ID is not valid": "", + "There are no products available. If you would like to assign this product to this attendee, please adjust the product\\'s available quantity.": "", + "There is an unexpected product price ID in the order": "", + "Product type cannot be changed as products have been registered for this type": "", + "The ordered category IDs must exactly match all categories for the event without missing or extra IDs.": "", + "The ordered product IDs must exactly match all products for the event without missing or extra IDs.": "", + "This product is outdated. Please reload the page.": "", + "Reserved": "", + "Cancelled": "", + "Completed": "", + "Awaiting offline payment": "", + "ID": "", + "First Name": "", + "Last Name": "", + "Email": "", + "Status": "", + "Is Checked In": "", + "Checked In At": "", + "Product ID": "", + "Product Name": "", + "Event ID": "", + "Public ID": "", + "Short ID": "", + "Created Date": "", + "Last Updated Date": "", + "Notes": "", + "Total Before Additions": "", + "Total Gross": "", + "Total Tax": "", + "Total Fee": "", + "Total Refunded": "", + "Payment Status": "", + "Refund Status": "", + "Currency": "", + "Created At": "", + "Payment Gateway": "", + "Is Partially Refunded": "", + "Is Fully Refunded": "", + "Is Free Order": "", + "Is Manually Created": "", + "Billing Address": "", + "Promo Code": "", + "Failed to handle incoming Stripe webhook": "", + "Notes must be less than 2000 characters": "", + "Invalid payment provider selected.": "", + "Payment instructions are required when offline payments are enabled.": "", + "The invoice prefix may only contain letters, numbers, and hyphens.": "", + "The organization name is required when invoicing is enabled.": "", + "The organization address is required when invoicing is enabled.": "", + "The invoice start number must be at least 1.": "", + "There is no default account configuration available": "", + "Product price ID is not valid": "", + "Invoice": "", + "Editing order with ID: :id": "", + "Marking order as paid": "", + "Received a :event Stripe event, which has no handler": "", + "Order is not in the correct status to transition to offline payment": "", + "Order reservation has expired": "", + "Offline payments are not enabled for this event": "", + "There are no products available in this category": "", + "Webhook not found": "", + "Unable to check in as attendee :attendee_name\\'s order is awaiting payment": "", + "Attendee :attendee_name\\'s order cannot be marked as paid. Please check your event settings": "", + "Invoice already exists": "", + "Invoice not found": "", + "Order is not awaiting offline payment": "", + "Refund already processed": "", + "Stripe refund successful": "", + "There are no tickets available for this event": "", + "Address line 1 is required": "", + "City is required": "", + "Zip or postal code is required": "", + "Country is required": "", + "If you did not request a password reset, please immediately reset your password.": "", + "ℹ️ Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "", + "ℹ️ This order is pending payment. Please mark the payment as received on the order management page once payment is received.": "", + "Order Status:": "", + "Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "", + "Payment Instructions": "", + "Please follow the instructions below to complete your payment.": "", + "Invoice Number": "", + "Date Issued": "", + "Due Date": "", + "Amount Due": "", + "Billed To": "", + "DESCRIPTION": "", + "RATE": "", + "QTY": "", + "AMOUNT": "", + "Subtotal": "", + "Total Discount": "", + "Total Service Fee": "", + "Total Amount": "", + "For any queries, please contact us at": "", + "Tax Information": "" } \ No newline at end of file diff --git a/backend/lang/vi.json b/backend/lang/vi.json new file mode 100644 index 0000000000..b7d97640af --- /dev/null +++ b/backend/lang/vi.json @@ -0,0 +1,386 @@ +{ + "Older First": "Cũ nhất trước", + "Newest First": "Mới nhất trước", + "Recently Updated First": "Cập nhật gần đây nhất", + "Recently Updated Last": "Cập nhật cũ nhất", + "First Name A-Z": "Tên A-Z", + "First Name Z-A": "Tên Z-A", + "Last Name A-Z": "Họ A-Z", + "Last Name Z-A": "Họ Z-A", + "Status A-Z": "Trạng thái A-Z", + "Status Z-A": "Trạng thái Z-A", + "Name A-Z": "Tên A-Z", + "Name Z-A": "Tên Z-A", + "Oldest first": "Cũ nhất trước", + "Newest first": "Mới nhất trước", + "Updated oldest first": "Cập nhật cũ nhất trước", + "Updated newest first": "Cập nhật mới nhất trước", + "Most capacity used": "Sức chứa đã dùng nhiều nhất", + "Least capacity used": "Sức chứa đã dùng ít nhất", + "Least capacity": "Sức chứa ít nhất", + "Most capacity": "Sức chứa nhiều nhất", + "Expires soonest": "Hết hạn sớm nhất", + "Expires latest": "Hết hạn muộn nhất", + "Closest start date": "Ngày bắt đầu gần nhất", + "Furthest start date": "Ngày bắt đầu xa nhất", + "Closest end date": "Ngày kết thúc gần nhất", + "Furthest end date": "Ngày kết thúc xa nhất", + "Recently Updated": "Mới cập nhật", + "Least Recently Updated": "Cập nhật cũ nhất", + "Sent Date Oldest": "Ngày gửi cũ nhất", + "Sent Date Newest": "Ngày gửi mới nhất", + "Subject A-Z": "Tiêu đề A-Z", + "Subject Z-A": "Tiêu đề Z-A", + "Oldest First": "Cũ nhất trước", + "Buyer Name A-Z": "Tên người mua A-Z", + "Buyer Name Z-A": "Tên người mua Z-A", + "Amount Ascending": "Số tiền tăng dần", + "Amount Descending": "Số tiền giảm dần", + "Buyer Email A-Z": "Email người mua A-Z", + "Buyer Email Z-A": "Email người mua Z-A", + "Order # Ascending": "Số đơn hàng tăng dần", + "Order # Descending": "Số đơn hàng giảm dần", + "Homepage order": "Thứ tự trang chủ", + "Title A-Z": "Tiêu đề A-Z", + "Title Z-A": "Tiêu đề Z-A", + "Sale start date closest": "Ngày bắt đầu bán gần nhất", + "Sale start date furthest": "Ngày bắt đầu bán xa nhất", + "Sale end date closest": "Ngày kết thúc bán gần nhất", + "Sale end date furthest": "Ngày kết thúc bán xa nhất", + "Code Name A-Z": "Tên mã A-Z", + "Code Name Z-A": "Tên mã Z-A", + "Usage Count Ascending": "Số lần sử dụng tăng dần", + "Usage Count Descending": "Số lần sử dụng giảm dần", + "Reserved": "Đã đặt", + "Cancelled": "Đã hủy", + "Completed": "Hoàn thành", + "Awaiting offline payment": "Đang chờ thanh toán offline", + "ID": "Mã số", + "First Name": "Tên", + "Last Name": "Họ", + "Email": "Email", + "Status": "Trạng thái", + "Is Checked In": "Đã check-in", + "Checked In At": "Thời gian check-in", + "Product ID": "Mã sản phẩm", + "Product Name": "Tên sản phẩm", + "Event ID": "Mã sự kiện", + "Public ID": "Mã công khai", + "Short ID": "Mã ngắn", + "Created Date": "Ngày tạo", + "Last Updated Date": "Ngày cập nhật cuối", + "Notes": "Ghi chú", + "Total Before Additions": "Tổng trước phụ phí", + "Total Gross": "Tổng gộp", + "Total Tax": "Tổng thuế", + "Total Fee": "Tổng phí", + "Total Refunded": "Tổng hoàn tiền", + "Payment Status": "Trạng thái thanh toán", + "Refund Status": "Trạng thái hoàn tiền", + "Currency": "Tiền tệ", + "Created At": "Tạo lúc", + "Payment Gateway": "Cổng thanh toán", + "Is Partially Refunded": "Đã hoàn tiền một phần", + "Is Fully Refunded": "Đã hoàn tiền toàn bộ", + "Is Free Order": "Là đơn hàng miễn phí", + "Is Manually Created": "Được tạo thủ công", + "Billing Address": "Địa chỉ thanh toán", + "Promo Code": "Mã khuyến mãi", + "Account registration is disabled": "Đăng ký tài khoản bị vô hiệu hóa", + "The invitation has expired": "Lời mời đã hết hạn", + "The invitation is invalid": "Lời mời không hợp lệ", + "Invitation valid, but user not found": "Lời mời hợp lệ, nhưng không tìm thấy người dùng", + "No user found for this invitation. The invitation may have been revoked.": "Không tìm thấy người dùng cho lời mời này. Lời mời có thể đã bị thu hồi.", + "Logout Successful": "Đăng xuất thành công", + "Your password has been reset. Please login with your new password.": "Mật khẩu của bạn đã được đặt lại. Vui lòng đăng nhập bằng mật khẩu mới của bạn.", + "No account ID found in token": "Không tìm thấy ID tài khoản trong mã thông báo", + "Failed to handle incoming Stripe webhook": "Xử lý webhook Stripe đến thất bại", + "Event with ID :eventId is not live and user is not authenticated": "Sự kiện với ID :eventId không hoạt động và người dùng chưa được xác thực", + "The email confirmation link has expired. Please request a new one.": "Liên kết xác nhận email đã hết hạn. Vui lòng yêu cầu một liên kết mới.", + "The email confirmation link is invalid.": "Liên kết xác nhận email không hợp lệ.", + "No invitation found for this user.": "Không tìm thấy lời mời cho người dùng này.", + "User status is not Invited": "Trạng thái người dùng không phải là Được mời", + "Email is required": "Email là bắt buộc", + "Email must be a valid email address": "Email phải là một địa chỉ email hợp lệ", + "First name is required": "Tên là bắt buộc", + "Last name is required": "Họ là bắt buộc", + "Product is required": "Sản phẩm là bắt buộc", + "Product price is required": "Giá sản phẩm là bắt buộc", + "Notes must be less than 2000 characters": "Ghi chú phải ít hơn 2000 ký tự", + "Please select at least one product.": "Vui lòng chọn ít nhất một sản phẩm.", + "The expiration date must be after the activation date.": "Ngày hết hạn phải sau ngày kích hoạt.", + "The activation date must be before the expiration date.": "Ngày kích hoạt phải trước ngày hết hạn.", + "Please enter a valid hex color code. In the format #000000 or #000.": "Vui lòng nhập mã màu hex hợp lệ. Theo định dạng #000000 hoặc #000.", + "The maximum timeout is 2 hours.": "Thời gian chờ tối đa là 2 giờ.", + "The address line 1 field is required": "Trường địa chỉ dòng 1 là bắt buộc", + "The city field is required": "Trường thành phố là bắt buộc", + "The zip or postal code field is required": "Trường mã bưu điện là bắt buộc", + "The country field is required": "Trường quốc gia là bắt buộc", + "The country field should be a 2 character ISO 3166 code": "Trường quốc gia phải là mã ISO 3166 gồm 2 ký tự", + "Invalid payment provider selected.": "Nhà cung cấp thanh toán không hợp lệ được chọn.", + "Payment instructions are required when offline payments are enabled.": "Hướng dẫn thanh toán là bắt buộc khi thanh toán offline được kích hoạt.", + "The invoice prefix may only contain letters, numbers, and hyphens.": "Tiền tố hóa đơn chỉ có thể chứa chữ cái, số và dấu gạch ngang.", + "The organization name is required when invoicing is enabled.": "Tên tổ chức là bắt buộc khi hóa đơn được kích hoạt.", + "The organization address is required when invoicing is enabled.": "Địa chỉ tổ chức là bắt buộc khi hóa đơn được kích hoạt.", + "The invoice start number must be at least 1.": "Số bắt đầu hóa đơn phải ít nhất là 1.", + "The sale end date must be after the sale start date.": "Ngày kết thúc bán phải sau ngày bắt đầu bán.", + "The sale end date must be a valid date.": "Ngày kết thúc bán phải là một ngày hợp lệ.", + "The sale start date must be after the product sale start date.": "Ngày bắt đầu bán phải sau ngày bắt đầu bán sản phẩm.", + "You must select a product category.": "Bạn phải chọn một danh mục sản phẩm.", + "Welcome to :app_name! Please confirm your email address": "Chào mừng đến với :app_name! Vui lòng xác nhận địa chỉ email của bạn", + "🎟️ Your Ticket for :event": "🎟️ Vé của bạn cho :event", + "Your order has been cancelled": "Đơn hàng của bạn đã bị hủy", + "Your order wasn\\'t successful": "Đơn hàng của bạn không thành công", + "You\\'ve received a refund": "Bạn đã nhận được hoàn tiền", + "Your Order is Confirmed!": "Đơn hàng của bạn đã được xác nhận!", + "We were unable to process your order": "Chúng tôi không thể xử lý đơn hàng của bạn", + "New order for :amount for :event 🎉": "Đơn hàng mới cho :amount cho :event 🎉", + "New order for :event 🎉": "Đơn hàng mới cho :event 🎉", + "Confirm email change": "Xác nhận thay đổi email", + "Password reset": "Đặt lại mật khẩu", + "Your password has been reset": "Mật khẩu của bạn đã được đặt lại", + "You\\'ve been invited to join :appName": "Bạn đã được mời tham gia :appName", + "Current account ID is not set": "ID tài khoản hiện tại chưa được đặt", + "User not found in this account": "Không tìm thấy người dùng trong tài khoản này", + "User not found": "Không tìm thấy người dùng", + "Invalid direction. Must be either asc or desc": "Hướng không hợp lệ. Phải là asc hoặc desc", + "DomainObject must be a valid :interface.": "DomainObject phải là một :interface hợp lệ.", + "Nested relationships must be an array of Relationship objects.": "Các mối quan hệ lồng nhau phải là một mảng các đối tượng Relationship.", + "OrderAndDirections must be an array of OrderAndDirection objects.": "OrderAndDirections phải là một mảng các đối tượng OrderAndDirection.", + "There is already an account associated with this email. Please log in instead.": "Đã có một tài khoản liên kết với email này. Vui lòng đăng nhập thay thế.", + "There is no default account configuration available": "Không có cấu hình tài khoản mặc định nào có sẵn", + "Stripe Connect Account creation is only available in Saas Mode.": "Tạo tài khoản Stripe Connect chỉ có sẵn trong chế độ Saas.", + "There are issues with creating or fetching the Stripe Connect Account. Please try again.": "Có vấn đề khi tạo hoặc lấy tài khoản Stripe Connect. Vui lòng thử lại.", + "There are issues with creating the Stripe Connect Account Link. Please try again.": "Có vấn đề khi tạo liên kết tài khoản Stripe Connect. Vui lòng thử lại.", + "Cannot check in attendee as they are not active.": "Không thể check-in người tham dự vì họ không hoạt động.", + "in": "vào", + "out": "ra", + "This ticket is invalid": "Vé này không hợp lệ", + "There are no tickets available. ' .\n 'If you would like to assign a product to this attendee,' .\n ' please adjust the product\\'s available quantity.": "Không có vé nào có sẵn. ' .\n 'Nếu bạn muốn gán một sản phẩm cho người tham dự này,' .\n ' vui lòng điều chỉnh số lượng sản phẩm có sẵn.", + "The product price ID is invalid.": "ID giá sản phẩm không hợp lệ.", + "Product ID is not valid": "ID sản phẩm không hợp lệ", + "Product price ID is not valid": "ID giá sản phẩm không hợp lệ", + "There are no products available. If you would like to assign this product to this attendee, please adjust the product\\'s available quantity.": "Không có sản phẩm nào có sẵn. Nếu bạn muốn gán sản phẩm này cho người tham dự này, vui lòng điều chỉnh số lượng sản phẩm có sẵn.", + "Attendee ID is not valid": "ID người tham dự không hợp lệ", + "The invitation does not exist": "Lời mời không tồn tại", + "The invitation has already been accepted": "Lời mời đã được chấp nhận", + "Check-in list not found": "Không tìm thấy danh sách check-in", + "Check-in list has expired": "Danh sách check-in đã hết hạn", + "Check-in list is not active yet": "Danh sách check-in chưa hoạt động", + "Event :id not found": "Không tìm thấy sự kiện :id", + "You cannot change the currency of an event that has completed orders": "Bạn không thể thay đổi tiền tệ của một sự kiện đã hoàn thành đơn hàng", + "You must verify your account before you can update an event\\'s status.\n You can resend the confirmation by visiting your profile page.": "Bạn phải xác minh tài khoản của mình trước khi có thể cập nhật trạng thái của sự kiện.\n Bạn có thể gửi lại xác nhận bằng cách truy cập trang hồ sơ của mình.", + "Continue": "Tiếp tục", + "Invoice": "Hóa đơn", + "You cannot send messages until your account is verified.": "Bạn không thể gửi tin nhắn cho đến khi tài khoản của bạn được xác minh.", + "Order not found": "Không tìm thấy đơn hàng", + "Order already cancelled": "Đơn hàng đã bị hủy", + "Failed to create attendee": "Tạo người tham dự thất bại", + "This order has already been processed": "Đơn hàng này đã được xử lý", + "This order has expired": "Đơn hàng này đã hết hạn", + "There is an unexpected product price ID in the order": "Có một ID giá sản phẩm không mong đợi trong đơn hàng", + "The number of attendees does not match the number of tickets in the order": "Số lượng người tham dự không khớp với số lượng vé trong đơn hàng", + "This event is not live.": "Sự kiện này không hoạt động.", + "Editing order with ID: :id": "Chỉnh sửa đơn hàng với ID: :id", + "Sorry, we could not verify your session. Please restart your order.": "Xin lỗi, chúng tôi không thể xác minh phiên của bạn. Vui lòng khởi động lại đơn hàng của bạn.", + "Marking order as paid": "Đánh dấu đơn hàng là đã thanh toán", + "Sorry, we could not verify your session. Please create a new order.": "Xin lỗi, chúng tôi không thể xác minh phiên của bạn. Vui lòng tạo đơn hàng mới.", + "Received a :event Stripe event, which has no handler": "Đã nhận được sự kiện Stripe :event, không có trình xử lý", + "There is no Stripe data associated with this order.": "Không có dữ liệu Stripe liên quan đến đơn hàng này.", + "There is already a refund pending for this order.\n Please wait for the refund to be processed before requesting another one.": "Đã có một khoản hoàn tiền đang chờ xử lý cho đơn hàng này.\n Vui lòng đợi hoàn tiền được xử lý trước khi yêu cầu một khoản khác.", + "Order is not in the correct status to transition to offline payment": "Đơn hàng không ở trạng thái đúng để chuyển sang thanh toán offline", + "Order reservation has expired": "Đặt chỗ đơn hàng đã hết hạn", + "Offline payments are not enabled for this event": "Thanh toán offline không được kích hoạt cho sự kiện này", + "Product type cannot be changed as products have been registered for this type": "Loại sản phẩm không thể thay đổi vì sản phẩm đã được đăng ký cho loại này", + "The ordered category IDs must exactly match all categories for the event without missing or extra IDs.": "Các ID danh mục đã đặt phải khớp chính xác với tất cả các danh mục cho sự kiện mà không thiếu hoặc thêm ID.", + "The ordered product IDs must exactly match all products for the event without missing or extra IDs.": "Các ID sản phẩm đã đặt phải khớp chính xác với tất cả các sản phẩm cho sự kiện mà không thiếu hoặc thêm ID.", + "There are no products available in this category": "Không có sản phẩm nào có sẵn trong danh mục này", + "The code :code is already in use for this event": "Mã :code đã được sử dụng cho sự kiện này", + "You cannot delete this question as there as answers associated with it. You can hide the question instead.": "Bạn không thể xóa câu hỏi này vì có các câu trả lời liên quan đến nó. Bạn có thể ẩn câu hỏi thay thế.", + "One or more of the ordered question IDs do not exist for the event.": "Một hoặc nhiều ID câu hỏi đã đặt không tồn tại cho sự kiện.", + "No email change pending": "Không có thay đổi email đang chờ xử lý", + "The email :email already exists on this account": "Email :email đã tồn tại trên tài khoản này", + "Webhook not found": "Không tìm thấy webhook", + "Username or Password are incorrect": "Tên người dùng hoặc mật khẩu không chính xác", + "Account not found": "Không tìm thấy tài khoản", + "Attempt to log in to a non-active account": "Cố gắng đăng nhập vào tài khoản không hoạt động", + "User account is not active": "Tài khoản người dùng không hoạt động", + "Invalid reset token": "Mã đặt lại không hợp lệ", + "Reset token has expired": "Mã đặt lại đã hết hạn", + "Attendee :attendee_name is not allowed to check in using this check-in list": "Người tham dự :attendee_name không được phép check-in bằng danh sách check-in này", + "Invalid attendee code detected: :attendees ": "Phát hiện mã người tham dự không hợp lệ: :attendees", + "Attendee :attendee_name is already checked in": "Người tham dự :attendee_name đã check-in", + "Attendee :attendee_name\\'s ticket is cancelled": "Vé của người tham dự :attendee_name đã bị hủy", + "Unable to check in as attendee :attendee_name\\'s order is awaiting payment": "Không thể check-in vì đơn hàng của người tham dự :attendee_name đang chờ thanh toán", + "Attendee :attendee_name\\'s order cannot be marked as paid. Please check your event settings": "Đơn hàng của người tham dự :attendee_name không thể được đánh dấu là đã thanh toán. Vui lòng kiểm tra cài đặt sự kiện của bạn", + "This attendee is not checked in": "Người tham dự này chưa check-in", + "Attendee does not belong to this check-in list": "Người tham dự không thuộc danh sách check-in này", + "Organizer :id not found": "Không tìm thấy nhà tổ chức :id", + "Event daily statistics updated for event :event_id with total refunded amount of :amount": "Thống kê hàng ngày của sự kiện được cập nhật cho sự kiện :event_id với tổng số tiền hoàn lại là :amount", + "Event statistics updated for event :event_id with total refunded amount of :amount": "Thống kê sự kiện được cập nhật cho sự kiện :event_id với tổng số tiền hoàn lại là :amount", + "Invoice already exists": "Hóa đơn đã tồn tại", + "Invoice not found": "Không tìm thấy hóa đơn", + "Order is not awaiting offline payment": "Đơn hàng không đang chờ thanh toán offline", + "This promo code is invalid": "Mã khuyến mãi này không hợp lệ", + "You haven\\'t selected any products": "Bạn chưa chọn sản phẩm nào", + "The maximum number of products available for :products is :max": "Số lượng sản phẩm tối đa có sẵn cho :products là :max", + "You must order at least :min products for :product": "Bạn phải đặt ít nhất :min sản phẩm cho :product", + "The minimum amount is :price": "Số tiền tối thiểu là :price", + "The product :product is sold out": "Sản phẩm :product đã hết hàng", + ":field must be specified": ":field phải được chỉ định", + "Invalid price ID": "ID giá không hợp lệ", + "The maximum number of products available for :product is :max": "Số lượng sản phẩm tối đa có sẵn cho :product là :max", + "Sorry, these products are sold out": "Xin lỗi, những sản phẩm này đã hết hàng", + "The maximum number of products available is :max": "Số lượng sản phẩm tối đa có sẵn là :max", + "Product with id :id not found": "Không tìm thấy sản phẩm với id :id", + "Refund already processed": "Hoàn tiền đã được xử lý", + "Stripe refund successful": "Hoàn tiền Stripe thành công", + "Failed to refund stripe charge": "Hoàn tiền Stripe thất bại", + "Payment was successful, but order has expired. Order: :id": "Thanh toán thành công, nhưng đơn hàng đã hết hạn. Đơn hàng: :id", + "Order is not awaiting payment. Order: :id": "Đơn hàng không đang chờ thanh toán. Đơn hàng: :id", + "There was an error communicating with the payment provider. Please try again later.": "Có lỗi khi giao tiếp với nhà cung cấp thanh toán. Vui lòng thử lại sau.", + "Stripe Connect account not found for the event organizer": "Không tìm thấy tài khoản Stripe Connect cho nhà tổ chức sự kiện", + "Cannot Refund: Stripe connect account not found and saas_mode_enabled is enabled": "Không thể hoàn tiền: Không tìm thấy tài khoản Stripe connect và chế độ saas_mode_enabled được kích hoạt", + "You cannot delete this product because it has orders associated with it. You can hide it instead.": "Bạn không thể xóa sản phẩm này vì nó có các đơn hàng", + "Invalid product ids: :ids": "ID sản phẩm không hợp lệ: :ids", + "Product is hidden without promo code": "Sản phẩm bị ẩn không có mã khuyến mãi", + "Product is sold out": "Sản phẩm đã bán hết", + "Product is before sale start date": "Sản phẩm trước ngày bắt đầu bán", + "Product is after sale end date": "Sản phẩm sau ngày kết thúc bán", + "Product is hidden": "Sản phẩm bị ẩn", + "Price is before sale start date": "Giá trước ngày bắt đầu bán", + "Price is after sale end date": "Giá sau ngày kết thúc bán", + "Price is sold out": "Giá đã bán hết", + "Price is hidden": "Giá bị ẩn", + "Cannot delete product price with id :id because it has sales": "Không thể xóa giá sản phẩm với id :id vì nó có doanh số", + "Order has no order items": "Đơn hàng không có mặt hàng nào", + "Tickets": "Vé", + "There are no tickets available for this event": "Không có vé nào cho sự kiện này", + "You cannot delete this product category because it contains the following products: :products. These products are linked to existing orders. Please move the :product_name to another category before attempting to delete this one.": "Bạn không thể xóa danh mục sản phẩm này vì nó chứa các sản phẩm sau: :products. Các sản phẩm này được liên kết với các đơn đặt hàng hiện có. Vui lòng di chuyển :product_name sang một danh mục khác trước khi cố gắng xóa danh mục này.", + "products": "sản phẩm", + "product": "sản phẩm", + "Product category :productCategoryId has been deleted.": "Danh mục sản phẩm :productCategoryId đã bị xóa.", + "You cannot delete the last product category. Please create another category before deleting this one.": "Bạn không thể xóa danh mục sản phẩm cuối cùng. Vui lòng tạo một danh mục khác trước khi xóa danh mục này.", + "The product category with ID :id was not found.": "Không tìm thấy danh mục sản phẩm có ID :id.", + "Promo code :code already exists": "Mã khuyến mãi :code đã tồn tại", + "Expired": "Đã hết hạn", + "Limit Reached": "Đã đạt giới hạn", + "Deleted": "Đã xóa", + "Active": "Hoạt động", + "Invalid calculation type": "Loại tính toán không hợp lệ", + "One or more tax IDs are invalid": "Một hoặc nhiều ID thuế không hợp lệ", + "You are not authorized to perform this action.": "Bạn không được phép thực hiện hành động này.", + "Your account is not active.": "Tài khoản của bạn chưa hoạt động.", + "Payload has expired or is invalid.": "Payload đã hết hạn hoặc không hợp lệ.", + "Payload could not be decrypted.": "Không thể giải mã Payload.", + "Could not upload image to :disk. Check :disk is configured correctly": "Không thể tải lên hình ảnh lên :disk. Kiểm tra :disk đã được cấu hình chính xác chưa", + "Could not upload image": "Không thể tải lên hình ảnh", + "Length must be a positive integer.": "Độ dài phải là một số nguyên dương.", + "Prefix length exceeds the total desired token length.": "Độ dài tiền tố vượt quá tổng độ dài mã thông báo mong muốn.", + "A valid email is required": "Yêu cầu một email hợp lệ", + "Address line 1 is required": "Yêu cầu địa chỉ dòng 1", + "City is required": "Yêu cầu thành phố", + "Zip or postal code is required": "Yêu cầu mã bưu điện", + "Country is required": "Yêu cầu quốc gia", + "The title field is required": "Yêu cầu tiêu đề", + "The attribute name is required": "Yêu cầu tên thuộc tính", + "The attribute value is required": "Yêu cầu giá trị thuộc tính", + "The attribute is_public fields is required": "Yêu cầu trường thuộc tính is_public", + "This field is required.": "Trường này là bắt buộc.", + "This field must be less than 255 characters.": "Trường này phải ít hơn 255 ký tự.", + "This field must be at least 2 characters.": "Trường này phải có ít nhất 2 ký tự.", + "Required questions have not been answered. You may need to reload the page.": "Các câu hỏi bắt buộc chưa được trả lời. Bạn có thể cần tải lại trang.", + "This product is outdated. Please reload the page.": "Sản phẩm này đã lỗi thời. Vui lòng tải lại trang.", + "This question is outdated. Please reload the page.": "Câu hỏi này đã lỗi thời. Vui lòng tải lại trang.", + "Please select an option": "Vui lòng chọn một tùy chọn", + "Hello": "Xin chào", + "You have requested to reset your password for your account on :appName.": "Bạn đã yêu cầu đặt lại mật khẩu cho tài khoản của bạn trên :appName.", + "Please click the link below to reset your password.": "Vui lòng nhấp vào liên kết bên dưới để đặt lại mật khẩu của bạn.", + "Reset Password": "Đặt lại mật khẩu", + "If you did not request a password reset, please ignore this email or reply to let us know.": "Nếu bạn không yêu cầu đặt lại mật khẩu, vui lòng bỏ qua email này hoặc trả lời để cho chúng tôi biết.", + "Thank you": "Cảm ơn bạn", + "Your password has been reset for your account on :appName.": "Mật khẩu của bạn đã được đặt lại cho tài khoản của bạn trên :appName.", + "If you did not request a password reset, please immediately reset your password.": "Nếu bạn không yêu cầu đặt lại mật khẩu, vui lòng đặt lại mật khẩu của bạn ngay lập tức.", + "You are receiving this communication because you are registered as an attendee for the following event:": "Bạn nhận được thông báo này vì bạn đã đăng ký làm người tham dự cho sự kiện sau:", + "If you believe you have received this email in error,": "Nếu bạn tin rằng bạn đã nhận được email này do nhầm lẫn,", + "please contact the event organizer at": "vui lòng liên hệ với nhà tổ chức sự kiện tại", + "If you believe this is spam, please report it to": "Nếu bạn tin rằng đây là thư rác, vui lòng báo cáo nó cho", + "You\\'re going to": "Bạn sẽ đến", + "ℹ️ Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "ℹ️ Đơn hàng của bạn đang chờ thanh toán. Vé đã được phát hành nhưng sẽ không hợp lệ cho đến khi nhận được thanh toán.", + "Please find your ticket details below.": "Vui lòng tìm chi tiết vé của bạn bên dưới.", + "View Ticket": "Xem vé", + "If you have any questions or need assistance, please reply to this email or contact the event organizer": "Nếu bạn có bất kỳ câu hỏi hoặc cần hỗ trợ, vui lòng trả lời email này hoặc liên hệ với nhà tổ chức sự kiện", + "at": "tại", + "Best regards,": "Trân trọng,", + "Your order for": "Đơn hàng của bạn cho", + "has been cancelled.": "đã bị hủy.", + "Order #:": "Mã đơn hàng:", + "If you have any questions or need assistance, please respond to this email.": "Nếu bạn có bất kỳ câu hỏi hoặc cần hỗ trợ, vui lòng trả lời email này.", + "Your recent order for": "Đơn hàng gần đây của bạn cho", + "was not successful.": "không thành công.", + "View Event Homepage": "Xem Trang chủ Sự kiện", + "If you have any questions or need assistance, feel free to reach out to our support team": "Nếu bạn có bất kỳ câu hỏi hoặc cần hỗ trợ, vui lòng liên hệ với nhóm hỗ trợ của chúng tôi", + "Best regards": "Trân trọng", + "You have received a refund of :refundAmount for the following event: :eventTitle.": "Bạn đã nhận được khoản hoàn trả :refundAmount cho sự kiện sau: :eventTitle.", + "You\\'ve received a new order!": "Bạn đã nhận được một đơn hàng mới!", + "Congratulations! You\\'ve received a new order for ": "Chúc mừng! Bạn đã nhận được một đơn hàng mới cho ", + "Please find the details below.": "Vui lòng xem chi tiết bên dưới.", + "ℹ️ This order is pending payment. Please mark the payment as received on the order management page once payment is received.": "ℹ️ Đơn hàng này đang chờ thanh toán. Vui lòng đánh dấu thanh toán là đã nhận trên trang quản lý đơn hàng sau khi nhận được thanh toán.", + "Order Amount:": "Số tiền đơn hàng:", + "Order ID:": "Mã đơn hàng:", + "Order Status:": "Trạng thái đơn hàng:", + "View Order": "Xem đơn hàng", + "Your recent order for :eventTitle was not successful. The order expired while you were completing the payment. We have issued a refund for the order.": "Đơn hàng gần đây của bạn cho :eventTitle không thành công. Đơn hàng đã hết hạn trong khi bạn đang hoàn tất thanh toán. Chúng tôi đã hoàn lại tiền cho đơn hàng.", + "We apologize for the inconvenience. If you have any questions or need assistance, feel free to reach us at": "Chúng tôi xin lỗi vì sự bất tiện này. Nếu bạn có bất kỳ câu hỏi hoặc cần hỗ trợ, vui lòng liên hệ với chúng tôi tại", + "View Event Page": "Xem Trang Sự kiện", + "Your Order is Confirmed! ": "Đơn hàng của bạn đã được xác nhận!", + "Congratulations! Your order for :eventTitle on :eventDate at :eventTime was successful. Please find your order details below.": "Chúc mừng! Đơn hàng của bạn cho :eventTitle vào :eventDate lúc :eventTime đã thành công. Vui lòng xem chi tiết đơn hàng của bạn bên dưới.", + "Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "Đơn hàng của bạn đang chờ thanh toán. Vé đã được phát hành nhưng sẽ không hợp lệ cho đến khi nhận được thanh toán.", + "Payment Instructions": "Hướng dẫn thanh toán", + "Please follow the instructions below to complete your payment.": "Vui lòng làm theo các hướng dẫn bên dưới để hoàn tất thanh toán của bạn.", + "Event Details": "Chi tiết sự kiện", + "Event Name:": "Tên sự kiện:", + "Date & Time:": "Ngày & Giờ:", + "Order Summary": "Tóm tắt đơn hàng", + "Order Number:": "Mã đơn hàng:", + "Total Amount:": "Tổng số tiền:", + "View Order Summary & Tickets": "Xem Tóm tắt Đơn hàng & Vé", + "If you have any questions or need assistance, feel free to reach out to our friendly support team at": "Nếu bạn có bất kỳ câu hỏi hoặc cần hỗ trợ, vui lòng liên hệ với nhóm hỗ trợ thân thiện của chúng tôi tại", + "What\\'s Next?": "Tiếp theo là gì?", + "Download Tickets:": "Tải vé xuống:", + "Please download your tickets from the order summary page.": "Vui lòng tải vé của bạn từ trang tóm tắt đơn hàng.", + "Prepare for the Event:": "Chuẩn bị cho sự kiện:", + "Make sure to note the event date, time, and location.": "Hãy chắc chắn ghi lại ngày, giờ và địa điểm sự kiện.", + "Stay Updated:": "Luôn cập nhật:", + "Keep an eye on your email for any updates from the event organizer.": "Theo dõi email của bạn để biết bất kỳ cập nhật nào từ nhà tổ chức sự kiện.", + "Hi :name": "Chào :name", + "Welcome to :appName! We\\'re excited to have you aboard!": "Chào mừng đến với :appName! Chúng tôi rất vui khi có bạn!", + "To get started and activate your account, please click the link below to confirm your email address:": "Để bắt đầu và kích hoạt tài khoản của bạn, vui lòng nhấp vào liên kết bên dưới để xác nhận địa chỉ email của bạn:", + "Confirm Your Email": "Xác nhận Email của Bạn", + "If you did not create an account with us, no further action is required. Your email address will not be used without confirmation.": "Nếu bạn không tạo tài khoản với chúng tôi, không cần thực hiện thêm hành động nào. Địa chỉ email của bạn sẽ không được sử dụng nếu không có xác nhận.", + "Best Regards,": "Trân trọng,", + "The :appName Team": "Đội ngũ :appName", + "You have requested to change your email address to :pendingEmail. Please click the link below to confirm this change.": "Bạn đã yêu cầu thay đổi địa chỉ email của mình thành :pendingEmail. Vui lòng nhấp vào liên kết bên dưới để xác nhận thay đổi này.", + "If you did not request this change, please immediately change your password.": "Nếu bạn không yêu cầu thay đổi này, vui lòng thay đổi mật khẩu của bạn ngay lập tức.", + "Thanks,": "Cảm ơn,", + "You\\'ve been invited to join :appName.": "Bạn đã được mời tham gia :appName.", + "To accept the invitation, please click the link below:": "Để chấp nhận lời mời, vui lòng nhấp vào liên kết bên dưới:", + "Accept Invitation": "Chấp nhận lời mời", + "Invoice Number": "Số hóa đơn", + "Date Issued": "Ngày phát hành", + "Due Date": "Ngày đáo hạn", + "Amount Due": "Số tiền đến hạn", + "Billed To": "Thanh toán cho", + "DESCRIPTION": "MÔ TẢ", + "RATE": "TỶ LỆ", + "QTY": "SỐ LƯỢNG", + "AMOUNT": "SỐ TIỀN", + "Subtotal": "Tổng phụ", + "Total Discount": "Tổng giảm giá", + "Total Service Fee": "Tổng phí dịch vụ", + "Total Amount": "Tổng số tiền", + "For any queries, please contact us at": "Mọi thắc mắc, vui lòng liên hệ với chúng tôi tại", + "Tax Information": "Thông tin thuế", + "All rights reserved.": "Đã đăng ký bản quyền.", + "Congratulations 🎉": "Chúc mừng 🎉" +} \ No newline at end of file diff --git a/backend/lang/zh-cn.json b/backend/lang/zh-cn.json index ac84d37b26..13f0b725e2 100644 --- a/backend/lang/zh-cn.json +++ b/backend/lang/zh-cn.json @@ -283,5 +283,141 @@ "Attendee does not belong to this check-in list": "该参与者不属于此签到列表", "Attendee :attendee_name\\'s ticket is cancelled": "参与者 :attendee_name 的票已被取消", "Check-in list is not active yet": "签到列表尚未激活", - "The number of attendees does not match the number of tickets in the order": "" -} \ No newline at end of file + "The number of attendees does not match the number of tickets in the order": "参加者的数量与订单中的票数不匹配。", + "Product is required": "需要产品。", + "Product price is required": "需要产品价格。", + "Please select at least one product.": "请选择至少一个产品。", + "The sale start date must be after the product sale start date.": "销售开始日期必须晚于产品销售开始日期。", + "You must select a product category.": "您必须选择一个产品类别。", + "Invalid direction. Must be either asc or desc": "方向无效。必须是升序 (asc) 或降序 (desc)。", + "DomainObject must be a valid :interface.": "DomainObject 必须是一个有效的 :interface。", + "Nested relationships must be an array of Relationship objects.": "嵌套关系必须是 Relationship 对象的数组。", + "OrderAndDirections must be an array of OrderAndDirection objects.": "OrderAndDirections 必须是 OrderAndDirection 对象的数组。", + "Attendee :attendee_name\\'s product is cancelled": "参加者 :attendee_name 的产品已取消。", + "Tickets": "票", + "There are no tickets available for this event.": "此活动没有可用的票。", + "You haven\\'t selected any products": "您尚未选择任何产品。", + "The maximum number of products available for :products is :max": ":products 的最大可用数量是 :max。", + "You must order at least :min products for :product": "您必须为 :product 至少订购 :min 件产品。", + "The product :product is sold out": "产品 :product 已售罄。", + "The maximum number of products available for :product is :max": "产品 :product 的最大可用数量是 :max。", + "Sorry, these products are sold out": "抱歉,这些产品已售罄。", + "The maximum number of products available is :max": "最大可用产品数量为 :max。", + "Product with id :id not found": "未找到 ID 为 :id 的产品。", + "You cannot delete this product because it has orders associated with it. You can hide it instead.": "您无法删除此产品,因为它已与订单关联。您可以将其隐藏。", + "Invalid product ids: :ids": "产品 ID 无效::ids。", + "Product is hidden without promo code": "没有促销代码时产品隐藏。", + "Product is sold out": "产品已售罄。", + "Product is before sale start date": "产品尚未到销售开始日期。", + "Product is after sale end date": "产品已超过销售结束日期。", + "Product is hidden": "产品已隐藏。", + "Cannot delete product price with id :id because it has sales": "无法删除 ID 为 :id 的产品价格,因为它已有销售记录。", + "You cannot delete this product category because it contains the following products: :products. These products are linked to existing orders. Please move the :product_name to another category before attempting to delete this one.": "您无法删除此产品类别,因为它包含以下产品::products。这些产品已关联到现有订单。请将 :product_name 移到另一个类别后再尝试删除。", + "products": "产品", + "product": "产品", + "Product category :productCategoryId has been deleted.": "产品类别 :productCategoryId 已被删除。", + "You cannot delete the last product category. Please create another category before deleting this one.": "您无法删除最后一个产品类别。请先创建另一个类别。", + "The product category with ID :id was not found.": "未找到 ID 为 :id 的产品类别。", + "Expired": "已过期", + "Limit Reached": "达到限制", + "Deleted": "已删除", + "Active": "活动中", + "This ticket is invalid": "此票无效。", + "There are no tickets available. ' .\n 'If you would like to assign a product to this attendee,' .\n ' please adjust the product\\'s available quantity.": "没有可用票。如果您想为此参加者分配产品,请调整产品的可用数量。", + "The product price ID is invalid.": "产品价格 ID 无效。", + "Product ID is not valid": "产品 ID 无效。", + "There are no products available. If you would like to assign this product to this attendee, please adjust the product\\'s available quantity.": "没有可用的产品。如果您想为此参加者分配此产品,请调整产品的可用数量。", + "There is an unexpected product price ID in the order": "订单中存在意外的产品价格 ID。", + "Product type cannot be changed as products have been registered for this type": "无法更改产品类型,因为此类型的产品已注册。", + "The ordered category IDs must exactly match all categories for the event without missing or extra IDs.": "所订购的类别 ID 必须与活动的所有类别完全匹配,不能有遗漏或多余的 ID。", + "The ordered product IDs must exactly match all products for the event without missing or extra IDs.": "所订购的产品 ID 必须与活动的所有产品完全匹配,不能有遗漏或多余的 ID。", + "This product is outdated. Please reload the page.": "此产品已过时。请重新加载页面。", + "Reserved": "已预订", + "Cancelled": "已取消", + "Completed": "已完成", + "Awaiting offline payment": "等待线下付款", + "ID": "ID", + "First Name": "名", + "Last Name": "姓", + "Email": "电子邮件", + "Status": "状态", + "Is Checked In": "已签到", + "Checked In At": "签到时间", + "Product ID": "产品 ID", + "Product Name": "产品名称", + "Event ID": "活动 ID", + "Public ID": "公开 ID", + "Short ID": "短 ID", + "Created Date": "创建日期", + "Last Updated Date": "最后更新日期", + "Notes": "备注", + "Total Before Additions": "添加前总计", + "Total Gross": "总金额", + "Total Tax": "总税额", + "Total Fee": "总费用", + "Total Refunded": "总退款金额", + "Payment Status": "支付状态", + "Refund Status": "退款状态", + "Currency": "货币", + "Created At": "创建时间", + "Payment Gateway": "支付网关", + "Is Partially Refunded": "部分退款", + "Is Fully Refunded": "全额退款", + "Is Free Order": "免费订单", + "Is Manually Created": "手动创建", + "Billing Address": "账单地址", + "Promo Code": "优惠码", + "Failed to handle incoming Stripe webhook": "处理 Stripe Webhook 失败", + "Notes must be less than 2000 characters": "备注必须少于 2000 个字符", + "Invalid payment provider selected.": "所选支付提供商无效。", + "Payment instructions are required when offline payments are enabled.": "启用线下支付时必须提供支付说明。", + "The invoice prefix may only contain letters, numbers, and hyphens.": "发票前缀只能包含字母、数字和连字符。", + "The organization name is required when invoicing is enabled.": "启用发票功能时,必须填写组织名称。", + "The organization address is required when invoicing is enabled.": "启用发票功能时,必须填写组织地址。", + "The invoice start number must be at least 1.": "发票起始编号必须至少为 1。", + "There is no default account configuration available": "没有可用的默认账户配置", + "Product price ID is not valid": "产品价格 ID 无效", + "Invoice": "发票", + "Editing order with ID: :id": "正在编辑订单,ID::id", + "Marking order as paid": "标记订单为已支付", + "Received a :event Stripe event, which has no handler": "收到 Stripe 事件 :event,但没有处理程序", + "Order is not in the correct status to transition to offline payment": "订单状态不正确,无法转换为线下支付", + "Order reservation has expired": "订单预订已过期", + "Offline payments are not enabled for this event": "此活动未启用线下支付", + "There are no products available in this category": "此类别中没有可用产品", + "Webhook not found": "Webhook 未找到", + "Unable to check in as attendee :attendee_name\\'s order is awaiting payment": "无法签到,因为参会者 :attendee_name 的订单正在等待支付", + "Attendee :attendee_name\\'s order cannot be marked as paid. Please check your event settings": "无法将参会者 :attendee_name 的订单标记为已支付。请检查您的活动设置", + "Invoice already exists": "发票已存在", + "Invoice not found": "未找到发票", + "Order is not awaiting offline payment": "订单未等待线下支付", + "Refund already processed": "退款已处理", + "Stripe refund successful": "Stripe 退款成功", + "There are no tickets available for this event": "此活动没有可用的门票", + "Address line 1 is required": "地址第一行必填", + "City is required": "城市必填", + "Zip or postal code is required": "邮政编码必填", + "Country is required": "国家必填", + "If you did not request a password reset, please immediately reset your password.": "如果您未请求重置密码,请立即重置您的密码。", + "ℹ️ Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "ℹ️ 您的订单正在等待支付。门票已发放,但在收到付款之前将无效。", + "ℹ️ This order is pending payment. Please mark the payment as received on the order management page once payment is received.": "ℹ️ 此订单正在等待支付。收到付款后,请在订单管理页面将其标记为已支付。", + "Order Status:": "订单状态:", + "Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "您的订单正在等待支付。门票已发放,但在收到付款之前将无效。", + "Payment Instructions": "支付说明", + "Please follow the instructions below to complete your payment.": "请按照以下说明完成您的支付。", + "Invoice Number": "发票编号", + "Date Issued": "开票日期", + "Due Date": "到期日期", + "Amount Due": "应付金额", + "Billed To": "账单收件人", + "DESCRIPTION": "描述", + "RATE": "费率", + "QTY": "数量", + "AMOUNT": "金额", + "Subtotal": "小计", + "Total Discount": "总折扣", + "Total Service Fee": "总服务费", + "Total Amount": "总金额", + "For any queries, please contact us at": "如有任何疑问,请联系我们:", + "Tax Information": "税务信息" +} diff --git a/backend/resources/views/emails/auth/reset-password-success.blade.php b/backend/resources/views/emails/auth/reset-password-success.blade.php index 147b84c1dd..2ddd66d43f 100644 --- a/backend/resources/views/emails/auth/reset-password-success.blade.php +++ b/backend/resources/views/emails/auth/reset-password-success.blade.php @@ -3,7 +3,7 @@ {{ __('Your password has been reset for your account on :appName.', ['appName' => config('app.name')]) }} -{{ __('If you did not request a password reset, please immediately contact reset your password.') }} +{{ __('If you did not request a password reset, please immediately reset your password.') }} {{ __('Thank you') }} diff --git a/backend/resources/views/emails/event/message.blade.php b/backend/resources/views/emails/event/message.blade.php index 867fa948a7..65386cc591 100644 --- a/backend/resources/views/emails/event/message.blade.php +++ b/backend/resources/views/emails/event/message.blade.php @@ -1,6 +1,6 @@ @php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp @php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp -@php /** @var \HiEvents\Services\Handlers\Message\DTO\SendMessageDTO $messageData */ @endphp +@php /** @var \HiEvents\Services\Application\Handlers\Message\DTO\SendMessageDTO $messageData */ @endphp @php /** @see \HiEvents\Mail\Event\EventMessage */ @endphp diff --git a/backend/resources/views/emails/orders/attendee-ticket.blade.php b/backend/resources/views/emails/orders/attendee-ticket.blade.php index dcad7287a3..74ba4dd2e9 100644 --- a/backend/resources/views/emails/orders/attendee-ticket.blade.php +++ b/backend/resources/views/emails/orders/attendee-ticket.blade.php @@ -1,9 +1,11 @@ @php use HiEvents\Helper\DateHelper; @endphp -@php /** @uses /backend/app/Mail/OrderSummary.php */ @endphp +@php /** @uses \HiEvents\Mail\Order\OrderSummary */ @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 string $ticketUrl */ @endphp @php /** @see \HiEvents\Mail\Attendee\AttendeeTicketMail */ @endphp @@ -12,6 +14,14 @@

+@if($order->isOrderAwaitingOfflinePayment()) +
+

+{{ __('ℹ️ Your order is pending payment. Tickets have been issued but will not be valid until payment is received.') }} +

+
+@endif + {{ __('Please find your ticket details below.') }} diff --git a/backend/resources/views/emails/orders/organizer/summary-for-organizer.blade.php b/backend/resources/views/emails/orders/organizer/summary-for-organizer.blade.php index dbfa0311d1..5dd6befa5a 100644 --- a/backend/resources/views/emails/orders/organizer/summary-for-organizer.blade.php +++ b/backend/resources/views/emails/orders/organizer/summary-for-organizer.blade.php @@ -12,47 +12,23 @@

+@if($order->isOrderAwaitingOfflinePayment()) +
+

+{{ __('ℹ️ This order is pending payment. Please mark the payment as received on the order management page once payment is received.') }} +

+
+@endif + {{ __('Order Amount:') }} {{ Currency::format($order->getTotalGross(), $event->getCurrency()) }}
-{{ __('Order ID:') }} {{ $order->getPublicId() }} +{{ __('Order ID:') }} {{ $order->getPublicId() }}
+{{ __('Order Status:') }} {{ $order->getHumanReadableStatus() }}
{{ __('View Order') }} -
- - - - - - - - - - @foreach ($order->getOrderItems() as $ticket) - - - - - @endforeach - - - - - -
{{ __('Ticket') }}{{ __('Price') }}{{ __('Total') }}
- {{ $ticket->getItemName() }} x {{ $ticket->getQuantity()}} - {{ Currency::format($ticket->getPrice() * $ticket->getQuantity(), $event->getCurrency()) }}
- {{ __('Total') }} - - {{ Currency::format($order->getTotalGross(), $event->getCurrency()) }} -
-
- -{{ __('Best regards') }}, -
-{{config('app.name')}} diff --git a/backend/resources/views/emails/orders/summary.blade.php b/backend/resources/views/emails/orders/summary.blade.php index 1ffe4918e8..e0b426b790 100644 --- a/backend/resources/views/emails/orders/summary.blade.php +++ b/backend/resources/views/emails/orders/summary.blade.php @@ -1,14 +1,30 @@ @php use Carbon\Carbon; use HiEvents\Helper\Currency; use HiEvents\Helper\DateHelper; @endphp @php /** @var \HiEvents\DomainObjects\OrderDomainObject $order */ @endphp @php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp +@php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp @php /** @var string $orderUrl */ @endphp @php /** @see \HiEvents\Mail\Order\OrderSummary */ @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')]) }}

+@else +
+

+{{ __('Your order is pending payment. Tickets have been issued but will not be valid until payment is received.') }} +

+ +
+

{{ __('Payment Instructions') }}

+{{ __('Please follow the instructions below to complete your payment.') }} +{!! $eventSettings->getOfflinePaymentInstructions() !!} +
+
+@endif

diff --git a/backend/resources/views/invoice.blade.php b/backend/resources/views/invoice.blade.php new file mode 100644 index 0000000000..86aa461ad7 --- /dev/null +++ b/backend/resources/views/invoice.blade.php @@ -0,0 +1,390 @@ +@php use Carbon\Carbon; @endphp +@php use HiEvents\Helper\Currency; @endphp +@php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp +@php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp +@php /** @var \HiEvents\DomainObjects\OrderDomainObject $order */ @endphp +@php /** @var \HiEvents\DomainObjects\InvoiceDomainObject $invoice */ @endphp + + + + + + + {{ $eventSettings->getInvoiceLabel() ?? __('Invoice') }} #{{ $invoice->getInvoiceNumber() }} + + + +

+

{{ $eventSettings->getInvoiceLabel() ?? __('Invoice') }}

+
+
{{ $eventSettings->getOrganizationName() }}
+
{!! $eventSettings->getOrganizationAddress() !!}
+ @if($eventSettings->getSupportEmail()) +
{{ $eventSettings->getSupportEmail() }}
+ @endif +
+
+ +
+ + + + + @if($invoice->getDueDate()) + + @endif + + +
+ {{ __('Invoice Number') }} + #{{ $invoice->getInvoiceNumber() }} + + {{ __('Date Issued') }} + {{ Carbon::parse($order->getCreatedAt())->format('d/m/Y') }} + + {{ __('Due Date') }} + {{ Carbon::parse($invoice->getDueDate())->format('d/m/Y') }} + + {{ __('Amount Due') }} + {{ Currency::format($order->getTotalGross(), $order->getCurrency()) }} +
+
+ +
+
{{ __('Billed To') }}
+
{{ $order->getFullName() }}
+
{{ $order->getEmail() }}
+ @if($order->getAddress()) +
{{ $order->getBillingAddressString() }}
+ @endif +
+ + + + + + + + + + + + @php $totalDiscount = 0; @endphp + @foreach($invoice->getItems() as $orderItem) + @php + $itemDiscount = 0; + if ($orderItem['price_before_discount']) { + $itemDiscount = ($orderItem['price_before_discount'] - $orderItem['price']) * $orderItem['quantity']; + $totalDiscount += $itemDiscount; + } + @endphp + + + + + + + @endforeach + +
{{ __('DESCRIPTION') }}{{ __('RATE') }}{{ __('QTY') }}{{ __('AMOUNT') }}
+ {{ $orderItem['item_name'] }} + @if(!empty($orderItem['description'])) +
{{ $orderItem['description'] }}
+ @endif +
+ @if($orderItem['price_before_discount']) +
{{ Currency::format($orderItem['price_before_discount'], $order->getCurrency()) }}
+
{{ Currency::format($orderItem['price'], $order->getCurrency()) }}
+ @else + {{ Currency::format($orderItem['price'], $order->getCurrency()) }} + @endif +
{{ $orderItem['quantity'] }} + @if($orderItem['price_before_discount']) +
{{ Currency::format($orderItem['price_before_discount'] * $orderItem['quantity'], $order->getCurrency()) }}
+
{{ Currency::format($orderItem['total_before_additions'], $order->getCurrency()) }}
+ @else + {{ Currency::format($orderItem['total_before_additions'], $order->getCurrency()) }} + @endif +
+ + + + + + + + @if($totalDiscount > 0) + + + + + @endif + + @if($order->getHasTaxes()) + @foreach($order->getTaxesAndFeesRollup()['taxes'] as $tax) + + + + + @endforeach + + + + + @endif + + @if($order->getHasFees()) + @foreach($order->getTaxesAndFeesRollup()['fees'] as $fee) + + + + + @endforeach + + + + + @endif + + + + + +
{{ __('Subtotal') }}{{ Currency::format($order->getTotalBeforeAdditions(), $order->getCurrency()) }}
{{ __('Total Discount') }}-{{ Currency::format($totalDiscount, $order->getCurrency()) }}
{{ $tax['name'] }} ({{ $tax['rate'] }}@if($tax['type'] === 'PERCENTAGE') + % + @else + {{ $order->getCurrency() }} + @endif){{ Currency::format($tax['value'], $order->getCurrency()) }}
{{ __('Total Tax') }}{{ Currency::format($order->getTotalTax(), $order->getCurrency()) }}
{{ $fee['name'] }} ({{ $fee['rate'] }}@if($fee['type'] === 'PERCENTAGE') + % + @else + {{ $order->getCurrency() }} + @endif){{ Currency::format($fee['value'], $order->getCurrency()) }}
{{ __('Total Service Fee') }}{{ Currency::format($order->getTotalFee(), $order->getCurrency()) }}
{{ __('Total Amount') }}{{ Currency::format($order->getTotalGross(), $order->getCurrency()) }}
+ +@if($eventSettings->getInvoiceNotes()) +
+ {!! $eventSettings->getInvoiceNotes() !!} +
+@endif + + + + diff --git a/backend/resources/views/vendor/mail/html/message.blade.php b/backend/resources/views/vendor/mail/html/message.blade.php index 317db982cd..1f66d344d2 100644 --- a/backend/resources/views/vendor/mail/html/message.blade.php +++ b/backend/resources/views/vendor/mail/html/message.blade.php @@ -6,7 +6,7 @@ @else - @endif @@ -30,7 +30,7 @@ @if($appEmailFooter = config('app.email_footer_text')) {{ $appEmailFooter }} @else - {{-- (c) Hi.Events Ltd 2024 --}} + {{-- (c) Hi.Events Ltd 2025 --}} {{-- PLEASE NOTE: --}} {{-- Hi.Events is licensed under the GNU Affero General Public License (AGPL) version 3. --}} {{-- You can find the full license text at: https://github.com/HiEventsDev/hi.events/blob/main/LICENSE --}} diff --git a/backend/resources/views/vendor/mail/html/themes/default.css b/backend/resources/views/vendor/mail/html/themes/default.css index 9da643b648..607cae0a21 100644 --- a/backend/resources/views/vendor/mail/html/themes/default.css +++ b/backend/resources/views/vendor/mail/html/themes/default.css @@ -232,10 +232,7 @@ img { .button-blue, .button-primary { background-color: #2d3748; - border-bottom: 8px solid #2d3748; - border-left: 18px solid #2d3748; - border-right: 18px solid #2d3748; - border-top: 8px solid #2d3748; + padding: 16px 48px; } .button-green, diff --git a/backend/resources/views/welcome.blade.php b/backend/resources/views/welcome.blade.php index da6d92bfab..6a11292ed3 100644 --- a/backend/resources/views/welcome.blade.php +++ b/backend/resources/views/welcome.blade.php @@ -20,7 +20,7 @@

{{__('Congratulations 🎉')}}

-

If you're seeing this Hi.events API is running

+

If you're seeing this Hi.Events API is running

diff --git a/backend/routes/api.php b/backend/routes/api.php index 540639f1df..2d951cebe8 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -41,6 +41,7 @@ use HiEvents\Http\Actions\Events\GetEventAction; use HiEvents\Http\Actions\Events\GetEventPublicAction; use HiEvents\Http\Actions\Events\GetEventsAction; +use HiEvents\Http\Actions\Events\GetOrganizerEventsPublicAction; use HiEvents\Http\Actions\Events\Images\CreateEventImageAction; use HiEvents\Http\Actions\Events\Images\DeleteEventImageAction; use HiEvents\Http\Actions\Events\Images\GetEventImagesAction; @@ -54,22 +55,39 @@ use HiEvents\Http\Actions\Messages\GetMessagesAction; use HiEvents\Http\Actions\Messages\SendMessageAction; use HiEvents\Http\Actions\Orders\CancelOrderAction; -use HiEvents\Http\Actions\Orders\CompleteOrderActionPublic; -use HiEvents\Http\Actions\Orders\CreateOrderActionPublic; +use HiEvents\Http\Actions\Orders\DownloadOrderInvoiceAction; +use HiEvents\Http\Actions\Orders\EditOrderAction; use HiEvents\Http\Actions\Orders\ExportOrdersAction; use HiEvents\Http\Actions\Orders\GetOrderAction; -use HiEvents\Http\Actions\Orders\GetOrderActionPublic; use HiEvents\Http\Actions\Orders\GetOrdersAction; +use HiEvents\Http\Actions\Orders\MarkOrderAsPaidAction; use HiEvents\Http\Actions\Orders\MessageOrderAction; use HiEvents\Http\Actions\Orders\Payment\RefundOrderAction; use HiEvents\Http\Actions\Orders\Payment\Stripe\CreatePaymentIntentActionPublic; use HiEvents\Http\Actions\Orders\Payment\Stripe\GetPaymentIntentActionPublic; +use HiEvents\Http\Actions\Orders\Public\CompleteOrderActionPublic; +use HiEvents\Http\Actions\Orders\Public\CreateOrderActionPublic; +use HiEvents\Http\Actions\Orders\Public\DownloadOrderInvoicePublicAction; +use HiEvents\Http\Actions\Orders\Public\GetOrderActionPublic; +use HiEvents\Http\Actions\Orders\Public\TransitionOrderToOfflinePaymentPublicAction; use HiEvents\Http\Actions\Orders\ResendOrderConfirmationAction; use HiEvents\Http\Actions\Organizers\CreateOrganizerAction; use HiEvents\Http\Actions\Organizers\EditOrganizerAction; use HiEvents\Http\Actions\Organizers\GetOrganizerAction; use HiEvents\Http\Actions\Organizers\GetOrganizerEventsAction; use HiEvents\Http\Actions\Organizers\GetOrganizersAction; +use HiEvents\Http\Actions\Organizers\GetPublicOrganizerAction; +use HiEvents\Http\Actions\ProductCategories\CreateProductCategoryAction; +use HiEvents\Http\Actions\ProductCategories\DeleteProductCategoryAction; +use HiEvents\Http\Actions\ProductCategories\EditProductCategoryAction; +use HiEvents\Http\Actions\ProductCategories\GetProductCategoriesAction; +use HiEvents\Http\Actions\ProductCategories\GetProductCategoryAction; +use HiEvents\Http\Actions\Products\CreateProductAction; +use HiEvents\Http\Actions\Products\DeleteProductAction; +use HiEvents\Http\Actions\Products\EditProductAction; +use HiEvents\Http\Actions\Products\GetProductAction; +use HiEvents\Http\Actions\Products\GetProductsAction; +use HiEvents\Http\Actions\Products\SortProductsAction; use HiEvents\Http\Actions\PromoCodes\CreatePromoCodeAction; use HiEvents\Http\Actions\PromoCodes\DeletePromoCodeAction; use HiEvents\Http\Actions\PromoCodes\GetPromoCodeAction; @@ -83,16 +101,11 @@ use HiEvents\Http\Actions\Questions\GetQuestionsAction; use HiEvents\Http\Actions\Questions\GetQuestionsPublicAction; use HiEvents\Http\Actions\Questions\SortQuestionsAction; +use HiEvents\Http\Actions\Reports\GetReportAction; use HiEvents\Http\Actions\TaxesAndFees\CreateTaxOrFeeAction; use HiEvents\Http\Actions\TaxesAndFees\DeleteTaxOrFeeAction; use HiEvents\Http\Actions\TaxesAndFees\EditTaxOrFeeAction; use HiEvents\Http\Actions\TaxesAndFees\GetTaxOrFeeAction; -use HiEvents\Http\Actions\Tickets\CreateTicketAction; -use HiEvents\Http\Actions\Tickets\DeleteTicketAction; -use HiEvents\Http\Actions\Tickets\EditTicketAction; -use HiEvents\Http\Actions\Tickets\GetTicketAction; -use HiEvents\Http\Actions\Tickets\GetTicketsAction; -use HiEvents\Http\Actions\Tickets\SortTicketsAction; use HiEvents\Http\Actions\Users\CancelEmailChangeAction; use HiEvents\Http\Actions\Users\ConfirmEmailAddressAction; use HiEvents\Http\Actions\Users\ConfirmEmailChangeAction; @@ -106,6 +119,12 @@ use HiEvents\Http\Actions\Users\ResendInvitationAction; use HiEvents\Http\Actions\Users\UpdateMeAction; use HiEvents\Http\Actions\Users\UpdateUserAction; +use HiEvents\Http\Actions\Webhooks\CreateWebhookAction; +use HiEvents\Http\Actions\Webhooks\DeleteWebhookAction; +use HiEvents\Http\Actions\Webhooks\EditWebhookAction; +use HiEvents\Http\Actions\Webhooks\GetWebhookAction; +use HiEvents\Http\Actions\Webhooks\GetWebhookLogsAction; +use HiEvents\Http\Actions\Webhooks\GetWebhooksAction; use Illuminate\Routing\Router; /** @var Router|Router $router */ @@ -113,14 +132,17 @@ $router->prefix('/auth')->group( function (Router $router): void { + // Auth $router->post('/login', LoginAction::class)->name('login'); $router->post('/logout', LogoutAction::class); $router->post('/register', CreateAccountAction::class); $router->post('/forgot-password', ForgotPasswordAction::class); + // Invitations $router->get('/invitation/{invite_token}', GetUserInvitationAction::class); $router->post('/invitation/{invite_token}', AcceptInvitationAction::class); + // Reset Passwords $router->get('/reset-password/{reset_token}', ValidateResetPasswordTokenAction::class); $router->post('/reset-password/{reset_token}', ResetPasswordAction::class); } @@ -131,9 +153,11 @@ function (Router $router): void { */ $router->middleware(['auth:api'])->group( function (Router $router): void { + // Auth $router->get('/auth/logout', LogoutAction::class); $router->post('/auth/refresh', RefreshTokenAction::class); + // Users $router->get('/users/me', GetMeAction::class); $router->put('/users/me', UpdateMeAction::class); $router->post('/users', CreateUserAction::class); @@ -148,10 +172,12 @@ function (Router $router): void { $router->post('/users/{user_id}/confirm-email/{token}', ConfirmEmailAddressAction::class); $router->post('/users/{user_id}/resend-email-confirmation', ResendEmailConfirmationAction::class); + // Accounts $router->get('/accounts/{account_id?}', GetAccountAction::class); $router->put('/accounts/{account_id?}', UpdateAccountAction::class); $router->post('/accounts/{account_id}/stripe/connect', CreateStripeConnectAccountAction::class); + // Organizers $router->post('/organizers', CreateOrganizerAction::class); // This is POST instead of PUT because you can't upload files via PUT in PHP (at least not easily) $router->post('/organizers/{organizer_id}', EditOrganizerAction::class); @@ -159,11 +185,13 @@ function (Router $router): void { $router->get('/organizers/{organizer_id}', GetOrganizerAction::class); $router->get('/organizers/{organizer_id}/events', GetOrganizerEventsAction::class); + // Taxes and Fees $router->post('/accounts/{account_id}/taxes-and-fees', CreateTaxOrFeeAction::class); $router->get('/accounts/{account_id}/taxes-and-fees', GetTaxOrFeeAction::class); $router->put('/accounts/{account_id}/taxes-and-fees/{tax_or_fee_id}', EditTaxOrFeeAction::class); $router->delete('/accounts/{account_id}/taxes-and-fees/{tax_or_fee_id}', DeleteTaxOrFeeAction::class); + // Events $router->post('/events', CreateEventAction::class); $router->get('/events', GetEventsAction::class); $router->get('/events/{event_id}', GetEventAction::class); @@ -171,15 +199,26 @@ function (Router $router): void { $router->put('/events/{event_id}/status', UpdateEventStatusAction::class); $router->post('/events/{event_id}/duplicate', DuplicateEventAction::class); - $router->post('/events/{event_id}/tickets', CreateTicketAction::class); - $router->post('/events/{event_id}/tickets/sort', SortTicketsAction::class); - $router->put('/events/{event_id}/tickets/{ticket_id}', EditTicketAction::class); - $router->get('/events/{event_id}/tickets/{ticket_id}', GetTicketAction::class); - $router->delete('/events/{event_id}/tickets/{ticket_id}', DeleteTicketAction::class); - $router->get('/events/{event_id}/tickets', GetTicketsAction::class); + // Product Categories + $router->post('/events/{event_id}/product-categories', CreateProductCategoryAction::class); + $router->get('/events/{event_id}/product-categories', GetProductCategoriesAction::class); + $router->get('/events/{event_id}/product-categories/{category_id}', GetProductCategoryAction::class); + $router->put('/events/{event_id}/product-categories/{category_id}', EditProductCategoryAction::class); + $router->delete('/events/{event_id}/product-categories/{category_id}', DeleteProductCategoryAction::class); + + // Products + $router->post('/events/{event_id}/products', CreateProductAction::class); + $router->post('/events/{event_id}/products/sort', SortProductsAction::class); + $router->put('/events/{event_id}/products/{ticket_id}', EditProductAction::class); + $router->get('/events/{event_id}/products/{ticket_id}', GetProductAction::class); + $router->delete('/events/{event_id}/products/{ticket_id}', DeleteProductAction::class); + $router->get('/events/{event_id}/products', GetProductsAction::class); + + // Stats $router->get('/events/{event_id}/check_in_stats', GetEventCheckInStatsAction::class); $router->get('/events/{event_id}/stats', GetEventStatsAction::class); + // Attendees $router->post('/events/{event_id}/attendees', CreateAttendeeAction::class); $router->get('/events/{event_id}/attendees', GetAttendeesAction::class); $router->get('/events/{event_id}/attendees/{attendee_id}', GetAttendeeAction::class); @@ -189,14 +228,19 @@ function (Router $router): void { $router->post('/events/{event_id}/attendees/{attendee_public_id}/resend-ticket', ResendAttendeeTicketAction::class); $router->post('/events/{event_id}/attendees/{attendee_public_id}/check_in', CheckInAttendeeAction::class); + // Orders $router->get('/events/{event_id}/orders', GetOrdersAction::class); $router->get('/events/{event_id}/orders/{order_id}', GetOrderAction::class); + $router->put('/events/{event_id}/orders/{order_id}', EditOrderAction::class); $router->post('/events/{event_id}/orders/{order_id}/message', MessageOrderAction::class); $router->post('/events/{event_id}/orders/{order_id}/refund', RefundOrderAction::class); $router->post('/events/{event_id}/orders/{order_id}/resend_confirmation', ResendOrderConfirmationAction::class); $router->post('/events/{event_id}/orders/{order_id}/cancel', CancelOrderAction::class); + $router->post('/events/{event_id}/orders/{order_id}/mark-as-paid', MarkOrderAsPaidAction::class); $router->post('/events/{event_id}/orders/export', ExportOrdersAction::class); + $router->get('/events/{event_id}/orders/{order_id}/invoice', DownloadOrderInvoiceAction::class); + // Questions $router->post('/events/{event_id}/questions', CreateQuestionAction::class); $router->put('/events/{event_id}/questions/{question_id}', EditQuestionAction::class); $router->get('/events/{event_id}/questions/{question_id}', GetQuestionAction::class); @@ -205,34 +249,51 @@ function (Router $router): void { $router->post('/events/{event_id}/questions/export', ExportOrdersAction::class); $router->post('/events/{event_id}/questions/sort', SortQuestionsAction::class); + // Images $router->post('/events/{event_id}/images', CreateEventImageAction::class); $router->get('/events/{event_id}/images', GetEventImagesAction::class); $router->delete('/events/{event_id}/images/{image_id}', DeleteEventImageAction::class); + // Promo Codes $router->post('/events/{event_id}/promo-codes', CreatePromoCodeAction::class); $router->put('/events/{event_id}/promo-codes/{promo_code_id}', UpdatePromoCodeAction::class); $router->get('/events/{event_id}/promo-codes', GetPromoCodesAction::class); $router->get('/events/{event_id}/promo-codes/{promo_code_id}', GetPromoCodeAction::class); $router->delete('/events/{event_id}/promo-codes/{promo_code_id}', DeletePromoCodeAction::class); + // Messages $router->post('/events/{event_id}/messages', SendMessageAction::class); $router->get('/events/{event_id}/messages', GetMessagesAction::class); + // Event Settings $router->get('/events/{event_id}/settings', GetEventSettingsAction::class); $router->put('/events/{event_id}/settings', EditEventSettingsAction::class); $router->patch('/events/{event_id}/settings', PartialEditEventSettingsAction::class); + // Capacity Assignments $router->post('/events/{event_id}/capacity-assignments', CreateCapacityAssignmentAction::class); $router->get('/events/{event_id}/capacity-assignments', GetCapacityAssignmentsAction::class); $router->get('/events/{event_id}/capacity-assignments/{capacity_assignment_id}', GetCapacityAssignmentAction::class); $router->put('/events/{event_id}/capacity-assignments/{capacity_assignment_id}', UpdateCapacityAssignmentAction::class); $router->delete('/events/{event_id}/capacity-assignments/{capacity_assignment_id}', DeleteCapacityAssignmentAction::class); + // Check-In Lists $router->post('/events/{event_id}/check-in-lists', CreateCheckInListAction::class); $router->get('/events/{event_id}/check-in-lists', GetCheckInListsAction::class); $router->get('/events/{event_id}/check-in-lists/{check_in_list_id}', GetCheckInListAction::class); $router->put('/events/{event_id}/check-in-lists/{check_in_list_id}', UpdateCheckInListAction::class); $router->delete('/events/{event_id}/check-in-lists/{check_in_list_id}', DeleteCheckInListAction::class); + + // Webhooks + $router->post('/events/{event_id}/webhooks', CreateWebhookAction::class); + $router->get('/events/{event_id}/webhooks', GetWebhooksAction::class); + $router->put('/events/{event_id}/webhooks/{webhook_id}', EditWebhookAction::class); + $router->get('/events/{event_id}/webhooks/{webhook_id}', GetWebhookAction::class); + $router->delete('/events/{event_id}/webhooks/{webhook_id}', DeleteWebhookAction::class); + $router->get('/events/{event_id}/webhooks/{webhook_id}/logs', GetWebhookLogsAction::class); + + // Reports + $router->get('/events/{event_id}/reports/{report_type}', GetReportAction::class); } ); @@ -244,13 +305,19 @@ function (Router $router): void { // Events $router->get('/events/{event_id}', GetEventPublicAction::class); - // Tickets - $router->get('/events/{event_id}/tickets', GetEventPublicAction::class); + // Organizers + $router->get('/organizers/{organizer_id}', GetPublicOrganizerAction::class); + $router->get('/organizers/{organizer_id}/events', GetOrganizerEventsPublicAction::class); + + // Products + $router->get('/events/{event_id}/products', GetEventPublicAction::class); // Orders $router->post('/events/{event_id}/order', CreateOrderActionPublic::class); $router->put('/events/{event_id}/order/{order_short_id}', CompleteOrderActionPublic::class); $router->get('/events/{event_id}/order/{order_short_id}', GetOrderActionPublic::class); + $router->post('/events/{event_id}/order/{order_short_id}/await-offline-payment', TransitionOrderToOfflinePaymentPublicAction::class); + $router->get('/events/{event_id}/order/{order_short_id}/invoice', DownloadOrderInvoicePublicAction::class); // Attendees $router->get('/events/{event_id}/attendees/{attendee_short_id}', GetAttendeeActionPublic::class); diff --git a/backend/routes/mail.php b/backend/routes/mail.php index 321574da7b..c0b0b313e8 100644 --- a/backend/routes/mail.php +++ b/backend/routes/mail.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; +use HiEvents\DomainObjects\Status\OrderStatus; use HiEvents\Mail\Organizer\OrderSummaryForOrganizer; use Illuminate\Support\Facades\Route; @@ -24,6 +25,7 @@ ->setId(2) ->setPublicId('123') ->setShortId('123') + ->setStatus(OrderStatus::COMPLETED->name) ->setOrderItems(collect([$orderItem, $orderItem2])); $organizer = (new OrganizerDomainObject()) diff --git a/backend/tests/Unit/Services/Handlers/Event/GetPublicEventHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php similarity index 74% rename from backend/tests/Unit/Services/Handlers/Event/GetPublicEventHandlerTest.php rename to backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php index a96224f20f..9dffed4623 100644 --- a/backend/tests/Unit/Services/Handlers/Event/GetPublicEventHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php @@ -1,15 +1,15 @@ eventRepository = m::mock(EventRepositoryInterface::class); $this->promoCodeRepository = m::mock(PromoCodeRepositoryInterface::class); - $this->ticketFilterService = m::mock(TicketFilterService::class); + $this->ticketFilterService = m::mock(ProductFilterService::class); $this->eventPageViewIncrementService = m::mock(EventPageViewIncrementService::class); $this->handler = new GetPublicEventHandler( @@ -41,14 +41,12 @@ protected function setUp(): void public function testHandleWithoutPromoCodeAndUnauthenticatedUser(): void { $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: false, ipAddress: '127.0.0.1', promoCode: null); - $tickets = collect(); - $event = m::mock(EventDomainObject::class); - $event->shouldReceive('setTickets')->once()->andReturnSelf(); - $event->shouldReceive('getTickets')->once()->andReturn($tickets); + $event = new EventDomainObject(); + $event->setProductCategories(collect()); $this->setupEventRepositoryMock($event, $data->eventId); $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull(); - $this->ticketFilterService->shouldReceive('filter')->once()->with($tickets, null)->andReturn(collect()); + $this->ticketFilterService->shouldReceive('filter')->once()->withAnyArgs()->andReturn(collect()); $this->eventPageViewIncrementService->shouldReceive('increment')->once()->with($data->eventId, $data->ipAddress); $this->handler->handle($data); @@ -57,16 +55,14 @@ public function testHandleWithoutPromoCodeAndUnauthenticatedUser(): void public function testHandleWithInvalidPromoCode(): void { $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: false, ipAddress: '127.0.0.1', promoCode: 'INVALID'); - $event = m::mock(EventDomainObject::class); - $tickets = collect(); - $event->shouldReceive('setTickets')->once()->andReturnSelf(); - $event->shouldReceive('getTickets')->once()->andReturn($tickets); + $event = new EventDomainObject(); + $event->setProductCategories(collect()); $promoCode = m::mock(PromoCodeDomainObject::class)->makePartial(); $promoCode->shouldReceive('isValid')->andReturn(false); $this->setupEventRepositoryMock($event, $data->eventId); $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturn($promoCode); - $this->ticketFilterService->shouldReceive('filter')->once()->with($tickets, null)->andReturn(collect()); + $this->ticketFilterService->shouldReceive('filter')->once()->withAnyArgs()->andReturn(collect()); $this->eventPageViewIncrementService->shouldReceive('increment')->once()->with($data->eventId, $data->ipAddress); $this->handler->handle($data); @@ -75,16 +71,14 @@ public function testHandleWithInvalidPromoCode(): void public function testHandleWithValidPromoCode(): void { $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: false, ipAddress: '127.0.0.1', promoCode: 'VALID'); - $tickets = collect(); - $event = m::mock(EventDomainObject::class); - $event->shouldReceive('setTickets')->once()->andReturnSelf(); - $event->shouldReceive('getTickets')->once()->andReturn($tickets); + $event = new EventDomainObject(); + $event->setProductCategories(collect()); $promoCode = m::mock(PromoCodeDomainObject::class)->makePartial(); $promoCode->shouldReceive('isValid')->andReturn(true); $this->setupEventRepositoryMock($event, $data->eventId); $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturn($promoCode); - $this->ticketFilterService->shouldReceive('filter')->once()->with($tickets, $promoCode)->andReturn(collect()); + $this->ticketFilterService->shouldReceive('filter')->once()->withAnyArgs()->andReturn(collect()); $this->eventPageViewIncrementService->shouldReceive('increment')->once()->with($data->eventId, $data->ipAddress); $this->handler->handle($data); diff --git a/backend/tests/Unit/Services/Handlers/Order/CompleteOrderHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php similarity index 73% rename from backend/tests/Unit/Services/Handlers/Order/CompleteOrderHandlerTest.php rename to backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php index a68f595ec1..d65f42255f 100644 --- a/backend/tests/Unit/Services/Handlers/Order/CompleteOrderHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php @@ -1,24 +1,27 @@ orderRepository = Mockery::mock(OrderRepositoryInterface::class); $this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class); $this->questionAnswersRepository = Mockery::mock(QuestionAnswerRepositoryInterface::class); - $this->ticketQuantityUpdateService = Mockery::mock(TicketQuantityUpdateService::class); - $this->ticketPriceRepository = Mockery::mock(TicketPriceRepositoryInterface::class); + $this->productQuantityUpdateService = Mockery::mock(ProductQuantityUpdateService::class); + $this->productPriceRepository = Mockery::mock(ProductPriceRepositoryInterface::class); + $this->webhookDispatchService = Mockery::mock(WebhookDispatchService::class); $this->completeOrderHandler = new CompleteOrderHandler( $this->orderRepository, $this->attendeeRepository, $this->questionAnswersRepository, - $this->ticketQuantityUpdateService, - $this->ticketPriceRepository + $this->productQuantityUpdateService, + $this->productPriceRepository, + $this->webhookDispatchService ); } @@ -80,12 +85,12 @@ public function testHandleSuccessfullyCompletesOrder(): void $this->orderRepository->shouldReceive('updateFromArray')->andReturn($updatedOrder); $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); - $this->ticketPriceRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$this->createMockTicketPrice()])); + $this->productPriceRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$this->createMockProductPrice()])); $this->attendeeRepository->shouldReceive('insert')->andReturn(true); - $this->attendeeRepository->shouldReceive('findWhere')->andReturn(new Collection([$this->createMockAttendee()])); + $this->attendeeRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$this->createMockAttendee()])); - $this->ticketQuantityUpdateService->shouldReceive('updateQuantitiesFromOrder'); + $this->productQuantityUpdateService->shouldReceive('updateQuantitiesFromOrder'); $this->completeOrderHandler->handle($orderShortId, $orderData); @@ -140,7 +145,7 @@ public function testHandleThrowsResourceConflictExceptionWhenOrderExpired(): voi $this->completeOrderHandler->handle($orderShortId, $orderData); } - public function testHandleUpdatesTicketQuantitiesForFreeOrder(): void + public function testHandleUpdatesProductQuantitiesForFreeOrder(): void { $orderShortId = 'ABC123'; $orderData = $this->createMockCompleteOrderDTO(); @@ -152,19 +157,23 @@ public function testHandleUpdatesTicketQuantitiesForFreeOrder(): void $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->orderRepository->shouldReceive('updateFromArray')->andReturn($updatedOrder); - $this->ticketPriceRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$this->createMockTicketPrice()])); + $this->productPriceRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$this->createMockProductPrice()])); $this->attendeeRepository->shouldReceive('insert')->andReturn(true); - $this->attendeeRepository->shouldReceive('findWhere')->andReturn(new Collection([$this->createMockAttendee()])); + $this->attendeeRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$this->createMockAttendee()])); + + $this->productQuantityUpdateService->shouldReceive('updateQuantitiesFromOrder')->once(); - $this->ticketQuantityUpdateService->shouldReceive('updateQuantitiesFromOrder')->once(); + $this->webhookDispatchService->shouldReceive('queueOrderWebhook') + ->with(WebhookEventType::ORDER_CREATED, $updatedOrder->getId()) + ->once(); $order = $this->completeOrderHandler->handle($orderShortId, $orderData); $this->assertSame($order->getStatus(), OrderStatus::COMPLETED->name); } - public function testHandleDoesNotUpdateTicketQuantitiesForPaidOrder(): void + public function testHandleDoesNotUpdateProductQuantitiesForPaidOrder(): void { $orderShortId = 'ABC123'; $orderData = $this->createMockCompleteOrderDTO(); @@ -177,12 +186,12 @@ public function testHandleDoesNotUpdateTicketQuantitiesForPaidOrder(): void $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->orderRepository->shouldReceive('updateFromArray')->andReturn($updatedOrder); - $this->ticketPriceRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$this->createMockTicketPrice()])); + $this->productPriceRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$this->createMockProductPrice()])); $this->attendeeRepository->shouldReceive('insert')->andReturn(true); - $this->attendeeRepository->shouldReceive('findWhere')->andReturn(new Collection([$this->createMockAttendee()])); + $this->attendeeRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$this->createMockAttendee()])); - $this->ticketQuantityUpdateService->shouldNotReceive('updateQuantitiesFromOrder'); + $this->productQuantityUpdateService->shouldNotReceive('updateQuantitiesFromOrder'); $this->completeOrderHandler->handle($orderShortId, $orderData); @@ -202,7 +211,7 @@ public function testHandleThrowsExceptionWhenAttendeeInsertFails(): void $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->orderRepository->shouldReceive('updateFromArray')->andReturn($updatedOrder); - $this->ticketPriceRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$this->createMockTicketPrice()])); + $this->productPriceRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$this->createMockProductPrice()])); $this->attendeeRepository->shouldReceive('insert')->andReturn(false); @@ -225,7 +234,7 @@ public function testExceptionIsThrowWhenAttendeeCountDoesNotMatchOrderItemsCount $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->orderRepository->shouldReceive('updateFromArray')->andReturn($updatedOrder); - $this->ticketPriceRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$this->createMockTicketPrice()])); + $this->productPriceRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$this->createMockProductPrice()])); $this->attendeeRepository->shouldReceive('insert')->andReturn(true); $this->attendeeRepository->shouldReceive('findWhere')->andReturn(new Collection()); @@ -242,16 +251,16 @@ private function createMockCompleteOrderDTO(): CompleteOrderDTO questions: null, ); - $attendeeDTO = new CompleteOrderAttendeeDTO( + $attendeeDTO = new CompleteOrderProductDataDTO( first_name: 'John', last_name: 'Doe', email: 'john@example.com', - ticket_price_id: 1 + product_price_id: 1 ); return new CompleteOrderDTO( order: $orderDTO, - attendees: new Collection([$attendeeDTO]) + products: new Collection([$attendeeDTO]) ); } @@ -274,26 +283,26 @@ private function createMockOrderItem(): OrderItemDomainObject|MockInterface { return (new OrderItemDomainObject()) ->setId(1) - ->setTicketId(1) + ->setProductId(1) ->setQuantity(1) ->setPrice(10) ->setTotalGross(10) - ->setTicketPriceId(1); + ->setProductPriceId(1); } - private function createMockTicketPrice(): TicketPriceDomainObject|MockInterface + private function createMockProductPrice(): ProductPriceDomainObject|MockInterface { - $ticketPrice = Mockery::mock(TicketPriceDomainObject::class); - $ticketPrice->shouldReceive('getId')->andReturn(1); - $ticketPrice->shouldReceive('getTicketId')->andReturn(1); - return $ticketPrice; + $productPrice = Mockery::mock(ProductPriceDomainObject::class); + $productPrice->shouldReceive('getId')->andReturn(1); + $productPrice->shouldReceive('getProductId')->andReturn(1); + return $productPrice; } private function createMockAttendee(): AttendeeDomainObject|MockInterface { $attendee = Mockery::mock(AttendeeDomainObject::class); $attendee->shouldReceive('getId')->andReturn(1); - $attendee->shouldReceive('getTicketId')->andReturn(1); + $attendee->shouldReceive('getProductId')->andReturn(1); return $attendee; } } diff --git a/backend/tests/Unit/Services/Domain/Event/CreateEventServiceTest.php b/backend/tests/Unit/Services/Domain/Event/CreateEventServiceTest.php index fece1a14d9..462b6380d9 100644 --- a/backend/tests/Unit/Services/Domain/Event/CreateEventServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Event/CreateEventServiceTest.php @@ -12,7 +12,8 @@ use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; use HiEvents\Services\Domain\Event\CreateEventService; -use HTMLPurifier; +use HiEvents\Services\Domain\ProductCategory\CreateProductCategoryService; +use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService; use Illuminate\Database\DatabaseManager; use Mockery; use Tests\TestCase; @@ -25,7 +26,7 @@ class CreateEventServiceTest extends TestCase private OrganizerRepositoryInterface $organizerRepository; private DatabaseManager $databaseManager; private EventStatisticRepositoryInterface $eventStatisticsRepository; - private HTMLPurifier $purifier; + private HtmlPurifierService $purifier; protected function setUp(): void { @@ -36,7 +37,8 @@ protected function setUp(): void $this->organizerRepository = Mockery::mock(OrganizerRepositoryInterface::class); $this->databaseManager = Mockery::mock(DatabaseManager::class); $this->eventStatisticsRepository = Mockery::mock(EventStatisticRepositoryInterface::class); - $this->purifier = Mockery::mock(HTMLPurifier::class); + $this->purifier = Mockery::mock(HtmlPurifierService::class); + $this->createProductCategoryService = Mockery::mock(CreateProductCategoryService::class); $this->createEventService = new CreateEventService( $this->eventRepository, @@ -60,8 +62,9 @@ public function testCreateEventSuccess(): void $eventSettings = $this->createMockEventSettingDomainObject(); $organizer = $this->createMockOrganizerDomainObject(); - $this->databaseManager->shouldReceive('beginTransaction')->once(); - $this->databaseManager->shouldReceive('commit')->once(); + $this->databaseManager->shouldReceive('transaction')->once()->andReturnUsing(function ($callback) { + return $callback(); + }); $this->organizerRepository->shouldReceive('findFirstWhere') ->with([ @@ -86,16 +89,23 @@ public function testCreateEventSuccess(): void $this->eventStatisticsRepository->shouldReceive('create') ->with(Mockery::on(function ($arg) use ($eventData) { return $arg['event_id'] === $eventData->getId() && - $arg['tickets_sold'] === 0 && + $arg['products_sold'] === 0 && $arg['sales_total_gross'] === 0; })); + $this->createProductCategoryService->shouldReceive('createCategory') + ->with( + 'Tickets', + false, + Mockery::any(), + null, + 'There are no tickets available for this event.' + ); $this->purifier->shouldReceive('purify')->andReturn('Test Description'); $result = $this->createEventService->createEvent($eventData, $eventSettings); - $this->assertInstanceOf(EventDomainObject::class, $result); $this->assertEquals($eventData->getId(), $result->getId()); } @@ -104,8 +114,9 @@ public function testCreateEventWithoutEventSettings(): void $eventData = $this->createMockEventDomainObject(); $organizer = $this->createMockOrganizerDomainObject(); - $this->databaseManager->shouldReceive('beginTransaction')->once(); - $this->databaseManager->shouldReceive('commit')->once(); + $this->databaseManager->shouldReceive('transaction')->once()->andReturnUsing(function ($callback) { + return $callback(); + }); $this->organizerRepository->shouldReceive('findFirstWhere')->andReturn($organizer); $this->eventRepository->shouldReceive('create')->andReturn($eventData); @@ -121,16 +132,27 @@ public function testCreateEventWithoutEventSettings(): void $this->eventStatisticsRepository->shouldReceive('create'); - $result = $this->createEventService->createEvent($eventData); + $this->createProductCategoryService->shouldReceive('createCategory') + ->with( + 'Tickets', + false, + Mockery::any(), + null, + 'There are no tickets available for this event.' + ); - $this->assertInstanceOf(EventDomainObject::class, $result); + $this->createEventService->createEvent($eventData); + + $this->assertTrue(true); } public function testCreateEventThrowsOrganizerNotFoundException(): void { $eventData = $this->createMockEventDomainObject(); - $this->databaseManager->shouldReceive('beginTransaction')->once(); + $this->databaseManager->shouldReceive('transaction')->once()->andReturnUsing(function ($callback) { + return $callback(); + }); $this->organizerRepository->shouldReceive('findFirstWhere')->andReturnNull(); @@ -173,6 +195,7 @@ private function createMockOrganizerDomainObject(): OrganizerDomainObject { return Mockery::mock(OrganizerDomainObject::class, function ($mock) { $mock->shouldReceive('getEmail')->andReturn('organizer@example.com'); + $mock->shouldReceive('getName')->andReturn('Organizer Name'); }); } } diff --git a/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php index 76a5c1af44..4f1f93bbba 100644 --- a/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\Services\Domain\Order; use HiEvents\DomainObjects\AttendeeDomainObject; +use HiEvents\DomainObjects\Enums\WebhookEventType; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\OrderDomainObject; @@ -12,7 +13,8 @@ use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Services\Domain\Order\OrderCancelService; -use HiEvents\Services\Domain\Ticket\TicketQuantityUpdateService; +use HiEvents\Services\Domain\Product\ProductQuantityUpdateService; +use HiEvents\Services\Infrastructure\Webhook\WebhookDispatchService; use Illuminate\Contracts\Mail\Mailer; use Illuminate\Database\DatabaseManager; use Illuminate\Support\Collection; @@ -27,8 +29,9 @@ class OrderCancelServiceTest extends TestCase private EventRepositoryInterface $eventRepository; private OrderRepositoryInterface $orderRepository; private DatabaseManager $databaseManager; - private TicketQuantityUpdateService $ticketQuantityService; + private ProductQuantityUpdateService $productQuantityService; private OrderCancelService $service; + private WebhookDispatchService $webhookDispatchService; protected function setUp(): void { @@ -39,7 +42,8 @@ protected function setUp(): void $this->eventRepository = m::mock(EventRepositoryInterface::class); $this->orderRepository = m::mock(OrderRepositoryInterface::class); $this->databaseManager = m::mock(DatabaseManager::class); - $this->ticketQuantityService = m::mock(TicketQuantityUpdateService::class); + $this->productQuantityService = m::mock(ProductQuantityUpdateService::class); + $this->webhookDispatchService = m::mock(WebhookDispatchService::class); $this->service = new OrderCancelService( mailer: $this->mailer, @@ -47,7 +51,8 @@ protected function setUp(): void eventRepository: $this->eventRepository, orderRepository: $this->orderRepository, databaseManager: $this->databaseManager, - ticketQuantityService: $this->ticketQuantityService, + productQuantityService: $this->productQuantityService, + webhookDispatchService: $this->webhookDispatchService, ); } @@ -60,8 +65,8 @@ public function testCancelOrder(): void $order->shouldReceive('getLocale')->andReturn('en'); $attendees = new Collection([ - m::mock(AttendeeDomainObject::class)->shouldReceive('getTicketPriceId')->andReturn(1)->mock(), - m::mock(AttendeeDomainObject::class)->shouldReceive('getTicketPriceId')->andReturn(2)->mock(), + m::mock(AttendeeDomainObject::class)->shouldReceive('getproductPriceId')->andReturn(1)->mock(), + m::mock(AttendeeDomainObject::class)->shouldReceive('getproductPriceId')->andReturn(2)->mock(), ]); $this->attendeeRepository @@ -75,7 +80,7 @@ public function testCancelOrder(): void $this->attendeeRepository->shouldReceive('updateWhere')->once(); - $this->ticketQuantityService->shouldReceive('decreaseQuantitySold')->twice(); + $this->productQuantityService->shouldReceive('decreaseQuantitySold')->twice(); $this->orderRepository->shouldReceive('updateWhere')->once(); @@ -100,6 +105,10 @@ public function testCancelOrder(): void return $mail instanceof OrderCancelled; }); + $this->webhookDispatchService->shouldReceive('queueOrderWebhook') + ->with(WebhookEventType::ORDER_CANCELLED, 1) + ->once(); + $this->databaseManager->shouldReceive('transaction')->once()->andReturnUsing(function ($callback) { $callback(); }); diff --git a/backend/tests/Unit/Services/Infrastructure/Session/CheckoutSessionManagementServiceTest.php b/backend/tests/Unit/Services/Infrastructure/Session/CheckoutSessionManagementServiceTest.php index 2c7af4288d..c38eb4a1d6 100644 --- a/backend/tests/Unit/Services/Infrastructure/Session/CheckoutSessionManagementServiceTest.php +++ b/backend/tests/Unit/Services/Infrastructure/Session/CheckoutSessionManagementServiceTest.php @@ -3,7 +3,9 @@ namespace Unit\Services\Infrastructure\Session; use HiEvents\Services\Infrastructure\Session\CheckoutSessionManagementService; +use Illuminate\Config\Repository; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Config; use Tests\TestCase; class CheckoutSessionManagementServiceTest extends TestCase @@ -17,7 +19,9 @@ public function testGetSessionIdWithExistingCookie(): void ->with('session_identifier') ->willReturn('existingSessionId'); - $service = new CheckoutSessionManagementService($request); + $configMock = $this->mock(Repository::class); + + $service = new CheckoutSessionManagementService($request, $configMock); $this->assertEquals('existingSessionId', $service->getSessionId()); } @@ -31,7 +35,9 @@ public function testVerifySession(): void ->with('session_identifier') ->willReturn('existingSessionId'); - $service = new CheckoutSessionManagementService($request); + $configMock = $this->mock(Repository::class); + + $service = new CheckoutSessionManagementService($request, $configMock); $this->assertTrue($service->verifySession('existingSessionId')); } @@ -45,7 +51,13 @@ public function testGetSessionCookie(): void ->with('session_identifier') ->willReturn('existingSessionId'); - $service = new CheckoutSessionManagementService($request); + $configMock = $this->mock(Repository::class) + ->shouldReceive('get') + ->with('session.domain') + ->andReturnNull() + ->getMock(); + + $service = new CheckoutSessionManagementService($request, $configMock); $cookie = $service->getSessionCookie(); diff --git a/backend/vapor.yml b/backend/vapor.yml new file mode 100644 index 0000000000..37a67d56e5 --- /dev/null +++ b/backend/vapor.yml @@ -0,0 +1,43 @@ +id: 68983 +name: HiEvents +environments: + production: + gateway-version: 2.0 + domain: api.hi.events + memory: 1524 + cli-memory: 512 + runtime: 'php-8.3:al2' + warm: 3 + cache: hievents-redis-prod + database: hievents-db-prod + queues: + - hievents-queue-prod + - hievents-webhook-queue-prod + queue-memory: 1024 + queue-concurrency: 5 + concurrency: 100 + build: + - 'composer install --no-dev' + - 'php artisan optimize' + deploy: + - 'php artisan migrate --force' + staging: + gateway-version: 2.0 + domain: staging-api.hi.events + memory: 1024 + cli-memory: 512 + runtime: 'php-8.3:al2' + warm: 3 + cache: hievents-redis-prod + database: hievents-db-prod + queue: + - hievents-queue-prod + - hievents-webhook-queue-staging + queue-memory: 1024 + queue-concurrency: 2 + concurrency: 100 + build: + - 'composer install --no-dev' + - 'php artisan optimize' + deploy: + - 'php artisan migrate --force' diff --git a/docker/all-in-one/.env b/docker/all-in-one/.env index a76047ce19..5bcc21e7af 100644 --- a/docker/all-in-one/.env +++ b/docker/all-in-one/.env @@ -1,4 +1,4 @@ -# See the README.md file for informaiton on how to generate the JWT_SECRET and APP_KEY +# See the README.md file for information on how to generate the JWT_SECRET and APP_KEY APP_KEY= JWT_SECRET= @@ -6,7 +6,7 @@ JWT_SECRET= VITE_FRONTEND_URL=http://localhost:8123 VITE_API_URL_CLIENT=http://localhost:8123/api VITE_API_URL_SERVER=http://localhost:80/api -VITE_STRIPE_PUBLISHABLE_KEY=pk_test +VITE_STRIPE_PUBLISHABLE_KEY=pk_test_123456789 # Backend variables # These values may not be suitable for production environments. @@ -14,12 +14,34 @@ VITE_STRIPE_PUBLISHABLE_KEY=pk_test # https://hi.events/docs/getting-started/deploying LOG_CHANNEL=stderr QUEUE_CONNECTION=sync + +# Application settings +APP_CDN_URL=http://localhost:8123/storage +APP_FRONTEND_URL=http://localhost:8123 +APP_DISABLE_REGISTRATION=false +APP_SAAS_MODE_ENABLED=false +APP_SAAS_STRIPE_APPLICATION_FEE_PERCENT=0 +APP_SAAS_STRIPE_APPLICATION_FEE_FIXED=0 + +# Email settings (Using log mailer for local testing) MAIL_MAILER=log +MAIL_DRIVER=log +MAIL_HOST=mail.local +MAIL_PORT=1025 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_ENCRYPTION=null +MAIL_FROM_ADDRESS=test@example.com +MAIL_FROM_NAME="Hi Events" +# File storage settings FILESYSTEM_PUBLIC_DISK=public FILESYSTEM_PRIVATE_DISK=local -APP_CDN_URL=http://localhost:8123/storage -APP_FRONTEND_URL=http://localhost:8123 - +# Database settings DATABASE_URL=postgresql://postgres:secret@postgres:5432/hi-events + +# Stripe settings (Replace with valid test keys if necessary) +STRIPE_PUBLIC_KEY=pk_test_123456789 +STRIPE_SECRET_KEY=sk_test_123456789 +STRIPE_WEBHOOK_SECRET=whsec_test_123456789 diff --git a/docker/all-in-one/docker-compose.yml b/docker/all-in-one/docker-compose.yml index 90f4528561..4aad7a177e 100644 --- a/docker/all-in-one/docker-compose.yml +++ b/docker/all-in-one/docker-compose.yml @@ -13,14 +13,31 @@ services: - VITE_STRIPE_PUBLISHABLE_KEY=${VITE_STRIPE_PUBLISHABLE_KEY} - LOG_CHANNEL=${LOG_CHANNEL} - QUEUE_CONNECTION=${QUEUE_CONNECTION} - - MAIL_MAILER=${MAIL_MAILER} - APP_KEY=${APP_KEY} + - APP_CDN_URL=${APP_CDN_URL} - APP_FRONTEND_URL=${APP_FRONTEND_URL} - JWT_SECRET=${JWT_SECRET} + - APP_DISABLE_REGISTRATION=${APP_DISABLE_REGISTRATION} + - APP_SAAS_MODE_ENABLED=${APP_SAAS_MODE_ENABLED} + - APP_SAAS_STRIPE_APPLICATION_FEE_PERCENT=${APP_SAAS_STRIPE_APPLICATION_FEE_PERCENT} + - APP_SAAS_STRIPE_APPLICATION_FEE_FIXED=${APP_SAAS_STRIPE_APPLICATION_FEE_FIXED} + - APP_EMAIL_LOGO_URL=${APP_EMAIL_LOGO_URL} + - APP_EMAIL_LOGO_LINK_URL=${APP_EMAIL_LOGO_LINK_URL} + - MAIL_MAILER=${MAIL_MAILER} + - MAIL_DRIVER=${MAIL_DRIVER} + - MAIL_HOST=${MAIL_HOST} + - MAIL_PORT=${MAIL_PORT} + - MAIL_USERNAME=${MAIL_USERNAME} + - MAIL_PASSWORD=${MAIL_PASSWORD} + - MAIL_ENCRYPTION=${MAIL_ENCRYPTION} + - MAIL_FROM_ADDRESS=${MAIL_FROM_ADDRESS} + - MAIL_FROM_NAME=${MAIL_FROM_NAME} - FILESYSTEM_PUBLIC_DISK=${FILESYSTEM_PUBLIC_DISK} - FILESYSTEM_PRIVATE_DISK=${FILESYSTEM_PRIVATE_DISK} - - APP_CDN_URL=${APP_CDN_URL} - DATABASE_URL=postgresql://postgres:secret@postgres:5432/hi-events + - STRIPE_PUBLIC_KEY=${STRIPE_PUBLIC_KEY} + - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY} + - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET} depends_on: postgres: diff --git a/docker/development/.env b/docker/development/.env index fa5cf33ec8..51c11796fd 100644 --- a/docker/development/.env +++ b/docker/development/.env @@ -5,7 +5,7 @@ APP_DEBUG=true API_URL_CLIENT=https://localhost:8443/api API_URL_SERVER=http://backend:80 -STRIPE_PUBLIC_KEY=pk_test_51J3J9vJ9J9vJ9 +STRIPE_PUBLIC_KEY=pk_test_XX FRONTEND_URL=https://localhost:8443 DB_CONNECTION=pgsql @@ -13,4 +13,4 @@ DB_HOST=pgsql DB_PORT=5432 DB_DATABASE=backend DB_USERNAME=username -DB_PASSWORD=password \ No newline at end of file +DB_PASSWORD=password diff --git a/frontend/.gitignore b/frontend/.gitignore index d600b6c76d..f540d06182 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -23,3 +23,5 @@ dist-ssr *.sln *.sw? + +.vercel \ No newline at end of file diff --git a/frontend/data/countries.json b/frontend/data/countries.json index 257c0f1b4c..872efeb498 100644 --- a/frontend/data/countries.json +++ b/frontend/data/countries.json @@ -1,252 +1,251 @@ [ - { "value": "", "label": "" }, - { "value": "AD", "label": "Andorra" }, - { "value": "AE", "label": "United Arab Emirates" }, + { "value": "", "label": "" }, { "value": "AF", "label": "Afghanistan" }, - { "value": "AG", "label": "Antigua and Barbuda" }, - { "value": "AI", "label": "Anguilla" }, + { "value": "AX", "label": "Åland Islands" }, { "value": "AL", "label": "Albania" }, - { "value": "AM", "label": "Armenia" }, + { "value": "DZ", "label": "Algeria" }, + { "value": "AS", "label": "American Samoa" }, + { "value": "AD", "label": "Andorra" }, { "value": "AO", "label": "Angola" }, + { "value": "AI", "label": "Anguilla" }, { "value": "AQ", "label": "Antarctica" }, + { "value": "AG", "label": "Antigua and Barbuda" }, { "value": "AR", "label": "Argentina" }, - { "value": "AS", "label": "American Samoa" }, - { "value": "AT", "label": "Austria" }, - { "value": "AU", "label": "Australia" }, + { "value": "AM", "label": "Armenia" }, { "value": "AW", "label": "Aruba" }, - { "value": "AX", "label": "Åland Islands" }, + { "value": "AU", "label": "Australia" }, + { "value": "AT", "label": "Austria" }, { "value": "AZ", "label": "Azerbaijan" }, - { "value": "BA", "label": "Bosnia and Herzegovina" }, - { "value": "BB", "label": "Barbados" }, + { "value": "BS", "label": "Bahamas" }, + { "value": "BH", "label": "Bahrain" }, { "value": "BD", "label": "Bangladesh" }, + { "value": "BB", "label": "Barbados" }, + { "value": "BY", "label": "Belarus" }, { "value": "BE", "label": "Belgium" }, - { "value": "BF", "label": "Burkina Faso" }, - { "value": "BG", "label": "Bulgaria" }, - { "value": "BH", "label": "Bahrain" }, - { "value": "BI", "label": "Burundi" }, + { "value": "BZ", "label": "Belize" }, { "value": "BJ", "label": "Benin" }, - { "value": "BL", "label": "Saint Barthélemy" }, { "value": "BM", "label": "Bermuda" }, - { "value": "BN", "label": "Brunei Darussalam" }, - { "value": "BO", "label": "Bolivia, Plurinational State of" }, - { "value": "BQ", "label": "Bonaire, Sint Eustatius and Saba" }, - { "value": "BR", "label": "Brazil" }, - { "value": "BS", "label": "Bahamas" }, { "value": "BT", "label": "Bhutan" }, - { "value": "BV", "label": "Bouvet Island" }, + { "value": "BO", "label": "Bolivia" }, + { "value": "BQ", "label": "Bonaire, Sint Eustatius and Saba" }, + { "value": "BA", "label": "Bosnia and Herzegovina" }, { "value": "BW", "label": "Botswana" }, - { "value": "BY", "label": "Belarus" }, - { "value": "BZ", "label": "Belize" }, + { "value": "BV", "label": "Bouvet Island" }, + { "value": "BR", "label": "Brazil" }, + { "value": "IO", "label": "British Indian Ocean Territory" }, + { "value": "BN", "label": "Brunei Darussalam" }, + { "value": "BG", "label": "Bulgaria" }, + { "value": "BF", "label": "Burkina Faso" }, + { "value": "BI", "label": "Burundi" }, + { "value": "CV", "label": "Cabo Verde" }, + { "value": "KH", "label": "Cambodia" }, + { "value": "CM", "label": "Cameroon" }, { "value": "CA", "label": "Canada" }, - { "value": "CC", "label": "Cocos (Keeling) Islands" }, - { "value": "CD", "label": "Congo, Democratic Republic of the" }, + { "value": "KY", "label": "Cayman Islands" }, { "value": "CF", "label": "Central African Republic" }, - { "value": "CG", "label": "Congo" }, - { "value": "CH", "label": "Switzerland" }, - { "value": "CI", "label": "Côte d'Ivoire" }, - { "value": "CK", "label": "Cook Islands" }, + { "value": "TD", "label": "Chad" }, { "value": "CL", "label": "Chile" }, - { "value": "CM", "label": "Cameroon" }, { "value": "CN", "label": "China" }, + { "value": "CX", "label": "Christmas Island" }, + { "value": "CC", "label": "Cocos (Keeling) Islands" }, { "value": "CO", "label": "Colombia" }, + { "value": "KM", "label": "Comoros" }, + { "value": "CG", "label": "Congo" }, + { "value": "CD", "label": "Congo, Democratic Republic of the" }, + { "value": "CK", "label": "Cook Islands" }, { "value": "CR", "label": "Costa Rica" }, + { "value": "CI", "label": "Côte d'Ivoire" }, + { "value": "HR", "label": "Croatia" }, { "value": "CU", "label": "Cuba" }, - { "value": "CV", "label": "Cabo Verde" }, { "value": "CW", "label": "Curaçao" }, - { "value": "CX", "label": "Christmas Island" }, { "value": "CY", "label": "Cyprus" }, { "value": "CZ", "label": "Czechia" }, - { "value": "DE", "label": "Germany" }, - { "value": "DJ", "label": "Djibouti" }, { "value": "DK", "label": "Denmark" }, + { "value": "DJ", "label": "Djibouti" }, { "value": "DM", "label": "Dominica" }, { "value": "DO", "label": "Dominican Republic" }, - { "value": "DZ", "label": "Algeria" }, { "value": "EC", "label": "Ecuador" }, - { "value": "EE", "label": "Estonia" }, { "value": "EG", "label": "Egypt" }, - { "value": "EH", "label": "Western Sahara" }, + { "value": "SV", "label": "El Salvador" }, + { "value": "GQ", "label": "Equatorial Guinea" }, { "value": "ER", "label": "Eritrea" }, - { "value": "ES", "label": "Spain" }, + { "value": "EE", "label": "Estonia" }, + { "value": "SZ", "label": "Eswatini" }, { "value": "ET", "label": "Ethiopia" }, - { "value": "FI", "label": "Finland" }, - { "value": "FJ", "label": "Fiji" }, { "value": "FK", "label": "Falkland Islands (Malvinas)" }, - { "value": "FM", "label": "Micronesia, Federated States of" }, { "value": "FO", "label": "Faroe Islands" }, + { "value": "FJ", "label": "Fiji" }, + { "value": "FI", "label": "Finland" }, { "value": "FR", "label": "France" }, + { "value": "GF", "label": "French Guiana" }, + { "value": "PF", "label": "French Polynesia" }, + { "value": "TF", "label": "French Southern Territories" }, { "value": "GA", "label": "Gabon" }, - { "value": "GB", "label": "United Kingdom of Great Britain and Northern Ireland" }, - { "value": "GD", "label": "Grenada" }, + { "value": "GM", "label": "Gambia" }, { "value": "GE", "label": "Georgia" }, - { "value": "GF", "label": "French Guiana" }, - { "value": "GG", "label": "Guernsey" }, + { "value": "DE", "label": "Germany" }, { "value": "GH", "label": "Ghana" }, { "value": "GI", "label": "Gibraltar" }, + { "value": "GR", "label": "Greece" }, { "value": "GL", "label": "Greenland" }, - { "value": "GM", "label": "Gambia" }, - { "value": "GN", "label": "Guinea" }, + { "value": "GD", "label": "Grenada" }, { "value": "GP", "label": "Guadeloupe" }, - { "value": "GQ", "label": "Equatorial Guinea" }, - { "value": "GR", "label": "Greece" }, - { "value": "GS", "label": "South Georgia and the South Sandwich Islands" }, - { "value": "GT", "label": "Guatemala" }, { "value": "GU", "label": "Guam" }, + { "value": "GT", "label": "Guatemala" }, + { "value": "GG", "label": "Guernsey" }, + { "value": "GN", "label": "Guinea" }, { "value": "GW", "label": "Guinea-Bissau" }, { "value": "GY", "label": "Guyana" }, - { "value": "HK", "label": "Hong Kong" }, + { "value": "HT", "label": "Haiti" }, { "value": "HM", "label": "Heard Island and McDonald Islands" }, + { "value": "VA", "label": "Holy See" }, { "value": "HN", "label": "Honduras" }, - { "value": "HR", "label": "Croatia" }, - { "value": "HT", "label": "Haiti" }, + { "value": "HK", "label": "Hong Kong" }, { "value": "HU", "label": "Hungary" }, + { "value": "IS", "label": "Iceland" }, + { "value": "IN", "label": "India" }, { "value": "ID", "label": "Indonesia" }, + { "value": "IR", "label": "Iran" }, + { "value": "IQ", "label": "Iraq" }, { "value": "IE", "label": "Ireland" }, - { "value": "IL", "label": "Israel" }, { "value": "IM", "label": "Isle of Man" }, - { "value": "IN", "label": "India" }, - { "value": "IO", "label": "British Indian Ocean Territory" }, - { "value": "IQ", "label": "Iraq" }, - { "value": "IR", "label": "Iran, Islamic Republic of" }, - { "value": "IS", "label": "Iceland" }, + { "value": "IL", "label": "Israel" }, { "value": "IT", "label": "Italy" }, - { "value": "JE", "label": "Jersey" }, { "value": "JM", "label": "Jamaica" }, - { "value": "JO", "label": "Jordan" }, { "value": "JP", "label": "Japan" }, + { "value": "JE", "label": "Jersey" }, + { "value": "JO", "label": "Jordan" }, + { "value": "KZ", "label": "Kazakhstan" }, { "value": "KE", "label": "Kenya" }, - { "value": "KG", "label": "Kyrgyzstan" }, - { "value": "KH", "label": "Cambodia" }, { "value": "KI", "label": "Kiribati" }, - { "value": "KM", "label": "Comoros" }, - { "value": "KN", "label": "Saint Kitts and Nevis" }, - { "value": "KP", "label": "Korea, Democratic People's Republic of" }, - { "value": "KR", "label": "Korea, Republic of" }, + { "value": "KP", "label": "North Korea" }, + { "value": "KR", "label": "South Korea" }, { "value": "KW", "label": "Kuwait" }, - { "value": "KY", "label": "Cayman Islands" }, - { "value": "KZ", "label": "Kazakhstan" }, + { "value": "KG", "label": "Kyrgyzstan" }, { "value": "LA", "label": "Lao People's Democratic Republic" }, + { "value": "LV", "label": "Latvia" }, { "value": "LB", "label": "Lebanon" }, - { "value": "LC", "label": "Saint Lucia" }, - { "value": "LI", "label": "Liechtenstein" }, - { "value": "LK", "label": "Sri Lanka" }, - { "value": "LR", "label": "Liberia" }, { "value": "LS", "label": "Lesotho" }, + { "value": "LR", "label": "Liberia" }, + { "value": "LY", "label": "Libya" }, + { "value": "LI", "label": "Liechtenstein" }, { "value": "LT", "label": "Lithuania" }, { "value": "LU", "label": "Luxembourg" }, - { "value": "LV", "label": "Latvia" }, - { "value": "LY", "label": "Libya" }, - { "value": "MA", "label": "Morocco" }, - { "value": "MC", "label": "Monaco" }, - { "value": "MD", "label": "Moldova, Republic of" }, - { "value": "ME", "label": "Montenegro" }, - { "value": "MF", "label": "Saint Martin, (French part)" }, + { "value": "MO", "label": "Macao" }, { "value": "MG", "label": "Madagascar" }, - { "value": "MH", "label": "Marshall Islands" }, - { "value": "MK", "label": "North Macedonia" }, + { "value": "MW", "label": "Malawi" }, + { "value": "MY", "label": "Malaysia" }, + { "value": "MV", "label": "Maldives" }, { "value": "ML", "label": "Mali" }, - { "value": "MM", "label": "Myanmar" }, - { "value": "MN", "label": "Mongolia" }, - { "value": "MO", "label": "Macao" }, - { "value": "MP", "label": "Northern Mariana Islands" }, + { "value": "MT", "label": "Malta" }, + { "value": "MH", "label": "Marshall Islands" }, { "value": "MQ", "label": "Martinique" }, { "value": "MR", "label": "Mauritania" }, - { "value": "MS", "label": "Montserrat" }, - { "value": "MT", "label": "Malta" }, { "value": "MU", "label": "Mauritius" }, - { "value": "MV", "label": "Maldives" }, - { "value": "MW", "label": "Malawi" }, + { "value": "YT", "label": "Mayotte" }, { "value": "MX", "label": "Mexico" }, - { "value": "MY", "label": "Malaysia" }, + { "value": "FM", "label": "Micronesia, Federated States of" }, + { "value": "MD", "label": "Moldova, Republic of" }, + { "value": "MC", "label": "Monaco" }, + { "value": "MN", "label": "Mongolia" }, + { "value": "ME", "label": "Montenegro" }, + { "value": "MS", "label": "Montserrat" }, + { "value": "MA", "label": "Morocco" }, { "value": "MZ", "label": "Mozambique" }, + { "value": "MM", "label": "Myanmar" }, { "value": "NA", "label": "Namibia" }, + { "value": "NR", "label": "Nauru" }, + { "value": "NP", "label": "Nepal" }, + { "value": "NL", "label": "Netherlands" }, { "value": "NC", "label": "New Caledonia" }, + { "value": "NZ", "label": "New Zealand" }, + { "value": "NI", "label": "Nicaragua" }, { "value": "NE", "label": "Niger" }, - { "value": "NF", "label": "Norfolk Island" }, { "value": "NG", "label": "Nigeria" }, - { "value": "NI", "label": "Nicaragua" }, - { "value": "NL", "label": "Netherlands" }, - { "value": "NO", "label": "Norway" }, - { "value": "NP", "label": "Nepal" }, - { "value": "NR", "label": "Nauru" }, { "value": "NU", "label": "Niue" }, - { "value": "NZ", "label": "New Zealand" }, + { "value": "NF", "label": "Norfolk Island" }, + { "value": "MP", "label": "Northern Mariana Islands" }, + { "value": "NO", "label": "Norway" }, { "value": "OM", "label": "Oman" }, + { "value": "PK", "label": "Pakistan" }, + { "value": "PW", "label": "Palau" }, + { "value": "PS", "label": "Palestine, State of" }, { "value": "PA", "label": "Panama" }, - { "value": "PE", "label": "Peru" }, - { "value": "PF", "label": "French Polynesia" }, { "value": "PG", "label": "Papua New Guinea" }, + { "value": "PY", "label": "Paraguay" }, + { "value": "PE", "label": "Peru" }, { "value": "PH", "label": "Philippines" }, - { "value": "PK", "label": "Pakistan" }, - { "value": "PL", "label": "Poland" }, - { "value": "PM", "label": "Saint Pierre and Miquelon" }, { "value": "PN", "label": "Pitcairn" }, - { "value": "PR", "label": "Puerto Rico" }, - { "value": "PS", "label": "Palestine, State of" }, + { "value": "PL", "label": "Poland" }, { "value": "PT", "label": "Portugal" }, - { "value": "PW", "label": "Palau" }, - { "value": "PY", "label": "Paraguay" }, + { "value": "PR", "label": "Puerto Rico" }, { "value": "QA", "label": "Qatar" }, { "value": "RE", "label": "Réunion" }, { "value": "RO", "label": "Romania" }, - { "value": "RS", "label": "Serbia" }, { "value": "RU", "label": "Russian Federation" }, { "value": "RW", "label": "Rwanda" }, + { "value": "BL", "label": "Saint Barthélemy" }, + { "value": "SH", "label": "Saint Helena, Ascension and Tristan da Cunha" }, + { "value": "KN", "label": "Saint Kitts and Nevis" }, + { "value": "LC", "label": "Saint Lucia" }, + { "value": "MF", "label": "Saint Martin, (French part)" }, + { "value": "PM", "label": "Saint Pierre and Miquelon" }, + { "value": "VC", "label": "Saint Vincent and the Grenadines" }, + { "value": "WS", "label": "Samoa" }, + { "value": "SM", "label": "San Marino" }, + { "value": "ST", "label": "Sao Tome and Principe" }, { "value": "SA", "label": "Saudi Arabia" }, - { "value": "SB", "label": "Solomon Islands" }, + { "value": "SN", "label": "Senegal" }, + { "value": "RS", "label": "Serbia" }, { "value": "SC", "label": "Seychelles" }, - { "value": "SD", "label": "Sudan" }, - { "value": "SE", "label": "Sweden" }, + { "value": "SL", "label": "Sierra Leone" }, { "value": "SG", "label": "Singapore" }, - { "value": "SH", "label": "Saint Helena, Ascension and Tristan da Cunha" }, - { "value": "SI", "label": "Slovenia" }, - { "value": "SJ", "label": "Svalbard and Jan Mayen" }, + { "value": "SX", "label": "Sint Maarten, (Dutch part)" }, { "value": "SK", "label": "Slovakia" }, - { "value": "SL", "label": "Sierra Leone" }, - { "value": "SM", "label": "San Marino" }, - { "value": "SN", "label": "Senegal" }, + { "value": "SI", "label": "Slovenia" }, + { "value": "SB", "label": "Solomon Islands" }, { "value": "SO", "label": "Somalia" }, - { "value": "SR", "label": "Suriname" }, + { "value": "ZA", "label": "South Africa" }, + { "value": "GS", "label": "South Georgia and the South Sandwich Islands" }, { "value": "SS", "label": "South Sudan" }, - { "value": "ST", "label": "Sao Tome and Principe" }, - { "value": "SV", "label": "El Salvador" }, - { "value": "SX", "label": "Sint Maarten, (Dutch part)" }, + { "value": "ES", "label": "Spain" }, + { "value": "LK", "label": "Sri Lanka" }, + { "value": "SD", "label": "Sudan" }, + { "value": "SR", "label": "Suriname" }, + { "value": "SJ", "label": "Svalbard and Jan Mayen" }, + { "value": "SE", "label": "Sweden" }, + { "value": "CH", "label": "Switzerland" }, { "value": "SY", "label": "Syrian Arab Republic" }, - { "value": "SZ", "label": "Eswatini" }, - { "value": "TC", "label": "Turks and Caicos Islands" }, - { "value": "TD", "label": "Chad" }, - { "value": "TF", "label": "French Southern Territories" }, - { "value": "TG", "label": "Togo" }, - { "value": "TH", "label": "Thailand" }, + { "value": "TW", "label": "Taiwan" }, { "value": "TJ", "label": "Tajikistan" }, - { "value": "TK", "label": "Tokelau" }, + { "value": "TZ", "label": "Tanzania, United Republic of" }, + { "value": "TH", "label": "Thailand" }, { "value": "TL", "label": "Timor-Leste" }, - { "value": "TM", "label": "Turkmenistan" }, - { "value": "TN", "label": "Tunisia" }, + { "value": "TG", "label": "Togo" }, + { "value": "TK", "label": "Tokelau" }, { "value": "TO", "label": "Tonga" }, - { "value": "TR", "label": "Türkiye" }, { "value": "TT", "label": "Trinidad and Tobago" }, + { "value": "TR", "label": "Türkiye (Turkey)" }, + { "value": "TN", "label": "Tunisia" }, + { "value": "TC", "label": "Turks and Caicos Islands" }, + { "value": "TM", "label": "Turkmenistan" }, { "value": "TV", "label": "Tuvalu" }, - { "value": "TW", "label": "Taiwan, Province of China" }, - { "value": "TZ", "label": "Tanzania, United Republic of" }, - { "value": "UA", "label": "Ukraine" }, { "value": "UG", "label": "Uganda" }, + { "value": "UA", "label": "Ukraine" }, + { "value": "AE", "label": "United Arab Emirates" }, + { "value": "GB", "label": "United Kingdom" }, { "value": "UM", "label": "United States Minor Outlying Islands" }, { "value": "US", "label": "United States of America" }, { "value": "UY", "label": "Uruguay" }, { "value": "UZ", "label": "Uzbekistan" }, - { "value": "VA", "label": "Holy See" }, - { "value": "VC", "label": "Saint Vincent and the Grenadines" }, + { "value": "VU", "label": "Vanuatu" }, { "value": "VE", "label": "Venezuela, Bolivarian Republic of" }, + { "value": "VN", "label": "Viet Nam" }, { "value": "VG", "label": "Virgin Islands, British" }, { "value": "VI", "label": "Virgin Islands, U.S." }, - { "value": "VN", "label": "Viet Nam" }, - { "value": "VU", "label": "Vanuatu" }, { "value": "WF", "label": "Wallis and Futuna" }, - { "value": "WS", "label": "Samoa" }, + { "value": "EH", "label": "Western Sahara" }, { "value": "YE", "label": "Yemen" }, - { "value": "YT", "label": "Mayotte" }, - { "value": "ZA", "label": "South Africa" }, { "value": "ZM", "label": "Zambia" }, { "value": "ZW", "label": "Zimbabwe" } -] \ No newline at end of file +] diff --git a/frontend/index.html b/frontend/index.html index 3aac4e1940..55b17e7ddb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10,6 +10,7 @@ Hi.Events +
diff --git a/frontend/lingui.config.ts b/frontend/lingui.config.ts index f9b96ef0ef..ba59a1b4b3 100644 --- a/frontend/lingui.config.ts +++ b/frontend/lingui.config.ts @@ -10,6 +10,7 @@ const config: LinguiConfig = { "ru", // Russian "de", // German "pt", // Portuguese (Portugal) + "vi", // Vietnamese // "it", // Italian // "pl", // Polish @@ -29,7 +30,7 @@ const config: LinguiConfig = { sourceLocale: "en", format: "po", fallbackLocales: { - "default": "en", + default: "en", } }; diff --git a/frontend/package.json b/frontend/package.json index 6ddec80f38..579dce4eae 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,7 @@ "build:ssr:client": "vite build --ssrManifest --outDir dist/client", "build:ssr:server": "vite build --ssr src/entry.server.tsx --outDir dist/server", "start": "cross-env NODE_ENV=production node server.js", - "build": "npm run build:ssr:client && npm run build:ssr:server", + "build": "npm run messages:extract && npm run messages:compile && npm run build:ssr:client && npm run build:ssr:server", "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "messages:extract": "lingui extract", "messages:compile": "lingui compile" @@ -23,24 +23,25 @@ "@dnd-kit/utilities": "^3.2.1", "@lingui/macro": "^4.7.0", "@lingui/react": "^4.7.0", - "@mantine/carousel": "^7.7.1", - "@mantine/charts": "^7.7.1", - "@mantine/core": "^7.7.1", - "@mantine/dates": "^7.7.1", - "@mantine/dropzone": "^7.7.1", - "@mantine/form": "^7.7.1", - "@mantine/hooks": "^7.7.1", - "@mantine/modals": "^7.7.1", - "@mantine/notifications": "^7.7.1", - "@mantine/nprogress": "^7.7.1", - "@mantine/tiptap": "^7.7.1", + "@mantine/carousel": "7.17.0", + "@mantine/charts": "7.17.0", + "@mantine/core": "7.17.0", + "@mantine/dates": "7.17.0", + "@mantine/dropzone": "7.17.0", + "@mantine/form": "7.17.0", + "@mantine/hooks": "7.17.0", + "@mantine/modals": "7.17.0", + "@mantine/notifications": "7.17.0", + "@mantine/nprogress": "7.17.0", + "@mantine/tiptap": "7.17.0", "@react-pdf/renderer": "^3.3.4", + "@react-router/node": "^7.1.5", "@remix-run/node": "^2.8.1", "@stripe/react-stripe-js": "^2.1.1", "@stripe/stripe-js": "^1.54.1", "@tabler/icons-react": "^2.44.0", "@tanstack/react-query": "5.52.2", - "@tiptap/extension-image": "^2.8.0", + "@tiptap/extension-image": "^2.11.5", "@tiptap/extension-link": "^2.1.13", "@tiptap/extension-text-align": "^2.1.13", "@tiptap/extension-underline": "^2.1.13", @@ -61,7 +62,8 @@ "react-dom": "^18.2.0", "react-helmet-async": "^2.0.4", "react-qr-code": "^2.0.12", - "react-router-dom": "^6.11.2", + "react-router": "^7.1.5", + "react-router-dom": "^7.1.5", "recharts": "2", "sirv": "^2.0.4" }, diff --git a/frontend/public/blank-slate/reports.svg b/frontend/public/blank-slate/reports.svg new file mode 100644 index 0000000000..1eda5cdddc --- /dev/null +++ b/frontend/public/blank-slate/reports.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/blank-slate/webhooks.svg b/frontend/public/blank-slate/webhooks.svg new file mode 100644 index 0000000000..df35385bf8 --- /dev/null +++ b/frontend/public/blank-slate/webhooks.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/login-background.jpg b/frontend/public/login-background.jpg new file mode 100644 index 0000000000..36b76d6fae Binary files /dev/null and b/frontend/public/login-background.jpg differ diff --git a/frontend/public/logo-dark.png b/frontend/public/logo-dark.png new file mode 100644 index 0000000000..4f2befcdbc Binary files /dev/null and b/frontend/public/logo-dark.png differ diff --git a/frontend/scripts/list_untranslated_strings.sh b/frontend/scripts/list_untranslated_strings.sh index bf8cc08fc9..031e64ff2e 100755 --- a/frontend/scripts/list_untranslated_strings.sh +++ b/frontend/scripts/list_untranslated_strings.sh @@ -3,7 +3,7 @@ # This script lists all untranslated strings in a .po file. # arbitrary translation file -poFile="../src/locales/es.po" +poFile="../src/locales/zh-cn.po" if [ -f "$poFile" ]; then echo "Checking file: $poFile" diff --git a/frontend/scripts/rename.sh b/frontend/scripts/rename.sh new file mode 100755 index 0000000000..84c92580c3 --- /dev/null +++ b/frontend/scripts/rename.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +replace_content() { + local file="$1" + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS version + sed -i '' -e 's/ticket/product/g; s/Ticket/Product/g; s/TICKET/PRODUCT/g' "$file" + else + # Linux version + sed -i 's/ticket/product/g; s/Ticket/Product/g; s/TICKET/PRODUCT/g' "$file" + fi +} + +rename_item() { + local item="$1" + local dir=$(dirname "$item") + local base=$(basename "$item") + local newbase=$(echo "$base" | sed 's/ticket/product/g; s/Ticket/Product/g; s/TICKET/PRODUCT/g') + + if [ "$base" != "$newbase" ]; then + mv "$item" "$dir/$newbase" + echo "Renamed: $item -> $dir/$newbase" + fi +} + +process_directory() { + local dir="$1" + + # First, rename directories (bottom-up to avoid path issues) + find "$dir" -depth -type d | while read -r item; do + if echo "$item" | grep -qi "ticket"; then + rename_item "$item" + fi + done + + # Then, find all files in the directory and its subdirectories + find "$dir" -type f | while read -r file; do + # Check if the file name contains "ticket" (case insensitive) + if echo "$file" | grep -qi "ticket"; then + rename_item "$file" + fi + + # Check if the file content contains "ticket" (case insensitive) + if grep -qi "ticket" "$file"; then + replace_content "$file" + echo "Modified content: $file" + fi + done +} + +if [ $# -eq 0 ]; then + echo "Usage: $0 " + exit 1 +fi + +if [ ! -d "$1" ]; then + echo "Error: $1 is not a directory" + exit 1 +fi + +process_directory "$1" + +# Remove any leftover -e files (backup files created by sed on some systems) +find "$1" -name "*-e" -type f -delete + +echo "Renaming and replacement complete." +echo "Removed any leftover -e backup files." diff --git a/frontend/server.js b/frontend/server.js index 9fa34d2c28..d4e8b7ae63 100644 --- a/frontend/server.js +++ b/frontend/server.js @@ -87,7 +87,15 @@ app.use("*", async (req, res) => { const envVariablesHtml = ``; + const headSnippets = []; + if (process.env.VITE_FATHOM_SITE_ID) { + headSnippets.push(` + + `); + } + const html = template + .replace("", headSnippets.join("\n")) .replace("", appHtml) .replace("", ``) .replace("", envVariablesHtml) diff --git a/frontend/src/api/attendee.client.ts b/frontend/src/api/attendee.client.ts index c420ea5084..ba44fb839d 100644 --- a/frontend/src/api/attendee.client.ts +++ b/frontend/src/api/attendee.client.ts @@ -8,8 +8,9 @@ export interface EditAttendeeRequest { first_name: string; last_name: string; email: string; - ticket_id?: IdParam; - ticket_price_id?: IdParam; + notes?: string; + product_id?: IdParam; + product_price_id?: IdParam; status?: string; } @@ -72,4 +73,4 @@ export const attendeeClientPublic = { const response = await publicApi.get>>(`events/${eventId}/attendees/${attendeeShortId}`); return response.data; }, -} \ No newline at end of file +} diff --git a/frontend/src/api/capacity-assignment.client.ts b/frontend/src/api/capacity-assignment.client.ts index 2819cbbfce..43a324e7c0 100644 --- a/frontend/src/api/capacity-assignment.client.ts +++ b/frontend/src/api/capacity-assignment.client.ts @@ -4,7 +4,8 @@ import { CapacityAssignmentRequest, GenericDataResponse, GenericPaginatedResponse, - IdParam, QueryFilters, + IdParam, + QueryFilters, } from "../types"; import {queryParamsHelper} from "../utilites/queryParamsHelper.ts"; diff --git a/frontend/src/api/check-in.client.ts b/frontend/src/api/check-in.client.ts index 98765ea492..93003796b0 100644 --- a/frontend/src/api/check-in.client.ts +++ b/frontend/src/api/check-in.client.ts @@ -18,9 +18,14 @@ export const publicCheckInClient = { const response = await publicApi.get>(`/check-in-lists/${checkInListShortId}/attendees` + queryParamsHelper.buildQueryString(pagination)); return response.data; }, - createCheckIn: async (checkInListShortId: IdParam, attendeePublicId: IdParam) => { + 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`, { - "attendee_public_ids": [attendeePublicId], + "attendees": [ + { + "public_id": attendeePublicId, + "action": action + } + ] }); return response.data; }, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 67342e3c6e..eae8e0b763 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -22,7 +22,7 @@ const ALLOWED_UNAUTHENTICATED_PATHS = [ 'print', '/order/', 'widget', - '/ticket/', + '/product/', 'check-in', ]; diff --git a/frontend/src/api/event.client.ts b/frontend/src/api/event.client.ts index 41ad35a2cf..4d7ad46c5f 100644 --- a/frontend/src/api/event.client.ts +++ b/frontend/src/api/event.client.ts @@ -82,6 +82,11 @@ export const eventsClient = { status }); 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); + return response.data; } } diff --git a/frontend/src/api/order.client.ts b/frontend/src/api/order.client.ts index 9ee16ec560..eb80c8001e 100644 --- a/frontend/src/api/order.client.ts +++ b/frontend/src/api/order.client.ts @@ -17,7 +17,7 @@ export interface OrderDetails { } export interface AttendeeDetails extends OrderDetails { - ticket_id: number, + product_id: number, } export interface FinaliseOrderPayload { @@ -25,25 +25,30 @@ export interface FinaliseOrderPayload { attendees: AttendeeDetails[], } +export interface EditOrderPayload { + first_name: string, + last_name: string, + email: string, + notes: string, +} -export interface TicketPriceQuantityFormValue { +export interface ProductPriceQuantityFormValue { price?: number, quantity: number, price_id: number, } -export interface TicketFormValue { - ticket_id: number, - quantities: TicketPriceQuantityFormValue[], +export interface ProductFormValue { + product_id: number, + quantities: ProductPriceQuantityFormValue[], } -export interface TicketFormPayload { - tickets?: TicketFormValue[], +export interface ProductFormPayload { + products?: ProductFormValue[], promo_code: string | null, session_identifier?: string, } - export interface RefundOrderPayload { amount: number; notify_buyer: boolean; @@ -85,10 +90,28 @@ export const orderClient = { return new Blob([response.data]); }, + + markAsPaid: async (eventId: IdParam, orderId: IdParam) => { + const response = await api.post>('events/' + eventId + '/orders/' + orderId + '/mark-as-paid'); + return response.data; + }, + + downloadInvoice: async (eventId: IdParam, orderId: IdParam): Promise => { + const response = await api.get(`events/${eventId}/orders/${orderId}/invoice`, { + responseType: 'blob', + }); + + return new Blob([response.data]); + }, + + editOrder: async (eventId: IdParam, orderId: IdParam, payload: EditOrderPayload) => { + const response = await api.put>(`events/${eventId}/orders/${orderId}`, payload); + return response.data; + } } export const orderClientPublic = { - create: async (eventId: number, createOrderPayload: TicketFormPayload) => { + create: async (eventId: number, createOrderPayload: ProductFormPayload) => { const response = await publicApi.post>('events/' + eventId + '/order', createOrderPayload); return response.data; }, @@ -118,4 +141,17 @@ export const orderClientPublic = { const response = await publicApi.put>(`events/${eventId}/order/${orderShortId}`, payload); return response.data; }, + + transitionToOfflinePayment: async (eventId: IdParam, orderShortId: IdParam) => { + const response = await publicApi.post>(`events/${eventId}/order/${orderShortId}/await-offline-payment`); + return response.data; + }, + + downloadInvoice: async (eventId: IdParam, orderShortId: IdParam): Promise => { + const response = await publicApi.get(`events/${eventId}/order/${orderShortId}/invoice`, { + responseType: 'blob', + }); + + return new Blob([response.data]); + }, } diff --git a/frontend/src/api/product-category.client.ts b/frontend/src/api/product-category.client.ts new file mode 100644 index 0000000000..38489384ba --- /dev/null +++ b/frontend/src/api/product-category.client.ts @@ -0,0 +1,45 @@ +import { api } from "./client"; +import { + ProductCategory, + GenericDataResponse, + IdParam, +} from "../types"; + +export const productCategoryClient = { + create: async (eventId: IdParam, productCategory: ProductCategory) => { + const response = await api.post>( + `events/${eventId}/product-categories`, + productCategory + ); + return response.data; + }, + + update: async (eventId: IdParam, productCategoryId: IdParam, productCategory: ProductCategory) => { + const response = await api.put>( + `events/${eventId}/product-categories/${productCategoryId}`, + productCategory + ); + return response.data; + }, + + all: async (eventId: IdParam) => { + const response = await api.get>( + `events/${eventId}/product-categories` + ); + return response.data; + }, + + get: async (eventId: IdParam, productCategoryId: IdParam) => { + const response = await api.get>( + `events/${eventId}/product-categories/${productCategoryId}` + ); + return response.data; + }, + + delete: async (eventId: IdParam, productCategoryId: IdParam) => { + const response = await api.delete>( + `events/${eventId}/product-categories/${productCategoryId}` + ); + return response.data; + }, +}; diff --git a/frontend/src/api/product.client.ts b/frontend/src/api/product.client.ts new file mode 100644 index 0000000000..ea8b9e5b71 --- /dev/null +++ b/frontend/src/api/product.client.ts @@ -0,0 +1,48 @@ +import {api} from "./client"; +import { + GenericDataResponse, + GenericPaginatedResponse, + IdParam, + QueryFilters, SortableItem, + Product, +} from "../types"; +import {queryParamsHelper} from "../utilites/queryParamsHelper.ts"; +import {publicApi} from "./public-client.ts"; + +export const productClient = { + findById: async (eventId: IdParam, productId: IdParam) => { + const response = await api.get>(`/events/${eventId}/products/${productId}`); + return response.data; + }, + all: async (eventId: IdParam, pagination: QueryFilters) => { + const response = await api.get>( + `/events/${eventId}/products` + queryParamsHelper.buildQueryString(pagination) + ); + return response.data; + }, + create: async (eventId: IdParam, product: Product) => { + const response = await api.post>(`events/${eventId}/products`, product); + return response.data; + }, + update: async (eventId: IdParam, productId: IdParam, product: Product) => { + const response = await api.put>(`events/${eventId}/products/${productId}`, product); + return response.data; + }, + delete: async (eventId: IdParam, productId: IdParam) => { + const response = await api.delete>(`/events/${eventId}/products/${productId}`); + return response.data; + }, + sortAllProducts: async (eventId: IdParam, sortedCategories: { product_category_id: IdParam, sorted_products: SortableItem[] }[]) => { + return await api.post(`/events/${eventId}/products/sort`, { + 'sorted_categories': sortedCategories, + }); + } +} + +export const productClientPublic = { + findByEventId: async (eventId: IdParam) => { + const response = await publicApi.get>(`/events/${eventId}/products`); + return response.data; + }, +} + diff --git a/frontend/src/api/public-client.ts b/frontend/src/api/public-client.ts index 23f7ff9fe3..9b31efda24 100644 --- a/frontend/src/api/public-client.ts +++ b/frontend/src/api/public-client.ts @@ -2,7 +2,9 @@ import axios from "axios"; import {isSsr} from "../utilites/helpers"; import {getConfig} from "../utilites/config"; -export const publicApi = axios.create(); +export const publicApi = axios.create({ + withCredentials: true, +}); const existingToken = typeof window !== "undefined" ? window?.localStorage?.getItem('token') : undefined; diff --git a/frontend/src/api/ticket.client.ts b/frontend/src/api/ticket.client.ts deleted file mode 100644 index 3d1f6e1aa9..0000000000 --- a/frontend/src/api/ticket.client.ts +++ /dev/null @@ -1,46 +0,0 @@ -import {api} from "./client"; -import { - GenericDataResponse, - GenericPaginatedResponse, - IdParam, - QueryFilters, SortableItem, - Ticket, -} from "../types"; -import {queryParamsHelper} from "../utilites/queryParamsHelper.ts"; -import {publicApi} from "./public-client.ts"; - -export const ticketClient = { - findById: async (eventId: IdParam, ticketId: IdParam) => { - const response = await api.get>(`/events/${eventId}/tickets/${ticketId}`); - return response.data; - }, - all: async (eventId: IdParam, pagination: QueryFilters) => { - const response = await api.get>( - `/events/${eventId}/tickets` + queryParamsHelper.buildQueryString(pagination) - ); - return response.data; - }, - create: async (eventId: IdParam, ticket: Ticket) => { - const response = await api.post>(`events/${eventId}/tickets`, ticket); - return response.data; - }, - update: async (eventId: IdParam, ticketId: IdParam, ticket: Ticket) => { - const response = await api.put>(`events/${eventId}/tickets/${ticketId}`, ticket); - return response.data; - }, - delete: async (eventId: IdParam, ticketId: IdParam) => { - const response = await api.delete>(`/events/${eventId}/tickets/${ticketId}`); - return response.data; - }, - sortTickets: async (eventId: IdParam, ticketSort: SortableItem[]) => { - return await api.post(`/events/${eventId}/tickets/sort`, ticketSort); - } -} - -export const ticketClientPublic = { - findByEventId: async (eventId: IdParam) => { - const response = await publicApi.get>(`/events/${eventId}/tickets`); - return response.data; - }, -} - diff --git a/frontend/src/api/webhook.client.ts b/frontend/src/api/webhook.client.ts new file mode 100644 index 0000000000..6df36196cb --- /dev/null +++ b/frontend/src/api/webhook.client.ts @@ -0,0 +1,34 @@ +import {GenericDataResponse, IdParam, Webhook, WebhookLog} from "../types"; +import {api} from "./client"; + +export interface WebhookRequest { + url: string; + event_types: string[]; + status: 'ENABLED' | 'PAUSED'; +} + +export const webhookClient = { + get: async (eventId: IdParam, webhookId: IdParam) => { + return await api.get>(`events/${eventId}/webhooks/${webhookId}`); + }, + + create: async (eventId: IdParam, webhook: WebhookRequest) => { + return await api.post>(`events/${eventId}/webhooks`, webhook); + }, + + all: async (eventId: IdParam) => { + return await api.get>(`events/${eventId}/webhooks`); + }, + + logs: async (eventId: IdParam, webhookId: IdParam) => { + return await api.get>(`events/${eventId}/webhooks/${webhookId}/logs`); + }, + + delete: async (eventId: IdParam, webhookId: IdParam) => { + return await api.delete(`events/${eventId}/webhooks/${webhookId}`); + }, + + update: async (eventId: IdParam, webhookId: IdParam, webhook: WebhookRequest) => { + return await api.put>(`events/${eventId}/webhooks/${webhookId}`, webhook); + }, +} diff --git a/frontend/src/components/common/Accordion/Accordion.module.scss b/frontend/src/components/common/Accordion/Accordion.module.scss new file mode 100644 index 0000000000..a195483f74 --- /dev/null +++ b/frontend/src/components/common/Accordion/Accordion.module.scss @@ -0,0 +1,54 @@ +.accordionItem { + border: 1px solid var(--mantine-color-gray-2); + border-radius: var(--mantine-radius-sm); + background-color: var(--mantine-color-white); + overflow: hidden; + + & + & { + margin-top: 0.75rem; + } +} + +.accordionControl { + background-color: var(--mantine-color-gray-0); + border-bottom: none; + + &:hover { + background-color: var(--mantine-color-gray-1); + } + + &[data-expanded] { + background-color: var(--mantine-color-white); + border-bottom: 1px solid var(--mantine-color-gray-2); + } +} + +.accordionContent { + padding: 1.25rem; + + @media (max-width: 768px) { + padding: 1rem; + } +} + +.accordionChevron { + transition: transform 0.2s ease; + + &[data-expanded] { + transform: rotate(180deg); + } +} + +.header { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.title { + flex: 1; +} + +.badge { + margin-left: auto; +} diff --git a/frontend/src/components/common/Accordion/index.tsx b/frontend/src/components/common/Accordion/index.tsx new file mode 100644 index 0000000000..208dd26375 --- /dev/null +++ b/frontend/src/components/common/Accordion/index.tsx @@ -0,0 +1,54 @@ +import {Accordion as MantineAccordion, Group, Text} from '@mantine/core'; +import {TablerIconsProps} from '@tabler/icons-react'; +import classes from './Accordion.module.scss'; +import React from "react"; + +export interface AccordionItem { + value: string; + icon?: (props: TablerIconsProps) => JSX.Element; + title: string; + count?: number; + hidden?: boolean; + content: React.ReactNode; +} + +interface AccordionProps { + items: AccordionItem[]; + defaultValue?: string; +} + +export const Accordion = ({items, defaultValue}: AccordionProps) => { + return ( + + {items + .filter((item) => !item.hidden) + .map((item) => ( + + + + {item.icon && } + {item.title} + {item.count !== undefined && ( + + ({item.count}) + + )} + + + + {item.content} + + + ))} + + ); +}; diff --git a/frontend/src/components/common/AddEventToCalendarButton/index.tsx b/frontend/src/components/common/AddEventToCalendarButton/index.tsx new file mode 100644 index 0000000000..d2cd5cb244 --- /dev/null +++ b/frontend/src/components/common/AddEventToCalendarButton/index.tsx @@ -0,0 +1,143 @@ +import {ActionIcon, Button, Popover, Stack, Text, Tooltip} from '@mantine/core'; +import {IconBrandGoogle, IconCalendarPlus, IconDownload} from '@tabler/icons-react'; +import {t} from "@lingui/macro"; + +interface LocationDetails { + venue_name?: string; + + [key: string]: any; +} + +interface EventSettings { + location_details?: LocationDetails; +} + +interface Event { + title: string; + description_preview?: string; + description?: string; + start_date: string; + end_date?: string; + settings?: EventSettings; +} + +interface AddToCalendarProps { + event: Event; +} + +const eventLocation = (event: Event): string => { + if (event.settings?.location_details) { + const details = event.settings.location_details; + const addressParts = []; + + if (details.street_address) addressParts.push(details.street_address); + if (details.street_address_2) addressParts.push(details.street_address_2); + if (details.city) addressParts.push(details.city); + if (details.state) addressParts.push(details.state); + if (details.postal_code) addressParts.push(details.postal_code); + if (details.country) addressParts.push(details.country); + + const address = addressParts.join(', '); + + if (details.venue_name) { + return `${details.venue_name}, ${address}`; + } + + return address; + } + + return ''; +}; + +const createICSContent = (event: Event): string => { + const formatDate = (date: string): string => { + return new Date(date).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); + }; + + const stripHtml = (html: string): string => { + const tmp = document.createElement('div'); + tmp.innerHTML = html || ''; + return tmp.textContent || tmp.innerText || ''; + }; + + return [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Hi.Events//NONSGML Event Calendar//EN', + 'CALSCALE:GREGORIAN', + 'BEGIN:VEVENT', + `DTSTART:${formatDate(event.start_date)}`, + `DTEND:${formatDate(event.end_date || event.start_date)}`, + `SUMMARY:${event.title.replace(/\n/g, '\\n')}`, + `DESCRIPTION:${stripHtml(event.description_preview || '').replace(/\n/g, '\\n')}`, + `LOCATION:${eventLocation(event)}`, + `DTSTAMP:${formatDate(new Date().toISOString())}`, + `UID:${crypto.randomUUID()}@hi.events`, + 'END:VEVENT', + 'END:VCALENDAR' + ].join('\r\n'); +}; + +const downloadICSFile = (event: Event): void => { + const content = createICSContent(event); + const blob = new Blob([content], {type: 'text/calendar;charset=utf-8'}); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.setAttribute('download', `${event.title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.ics`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +}; + +const createGoogleCalendarUrl = (event: Event): string => { + const formatGoogleDate = (date: string): string => { + return new Date(date).toISOString().replace(/-|:|\.\d{3}/g, ''); + }; + + const params = new URLSearchParams({ + action: 'TEMPLATE', + text: event.title, + details: event.description_preview || '', + location: eventLocation(event), + dates: `${formatGoogleDate(event.start_date)}/${formatGoogleDate(event.end_date || event.start_date)}` + }); + + return `https://calendar.google.com/calendar/render?${params.toString()}`; +}; + +export const AddToEventCalendarButton = ({event}: AddToCalendarProps) => { + return ( + + + + + + + + + + + {t`Add to Calendar`} + + + + + + ); +}; diff --git a/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx b/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx index fd5cf84de2..990dd7f5ad 100644 --- a/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx +++ b/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx @@ -8,7 +8,7 @@ import {showError} from "../../../utilites/notifications.tsx"; import {t, Trans} from "@lingui/macro"; interface QRScannerComponentProps { - onCheckIn: (attendeePublicId: string, onRequestComplete: (didSucceed: boolean) => void, onFailure: () => void) => void; + onAttendeeScanned: (attendeePublicId: string) => void; onClose: () => void; } @@ -42,7 +42,6 @@ export const QRScannerComponent = (props: QRScannerComponentProps) => { localStorage.setItem("qrScannerSoundOn", JSON.stringify(isSoundOn)); }, [isSoundOn]); - useEffect(() => { latestProcessedAttendeeIdsRef.current = processedAttendeeIds; }, [processedAttendeeIds]); @@ -78,9 +77,7 @@ export const QRScannerComponent = (props: QRScannerComponentProps) => { showError(t`You already scanned this ticket`); setIsScanFailed(true); - setInterval(function () { - setIsScanFailed(false); - }, 500); + setInterval(() => setIsScanFailed(false), 500); if (isSoundOn && scanErrorAudioRef.current) { scanErrorAudioRef.current.play(); } @@ -90,38 +87,20 @@ export const QRScannerComponent = (props: QRScannerComponentProps) => { if (!isCheckingIn && !alreadyScanned) { setIsCheckingIn(true); - if (isSoundOn && scanInProgressAudioRef.current) { scanInProgressAudioRef.current.play(); } - props.onCheckIn(debouncedAttendeeId, (didSucceed) => { - setIsCheckingIn(false); - setProcessedAttendeeIds(prevIds => [...prevIds, debouncedAttendeeId]); - setCurrentAttendeeId(null); - - if (didSucceed) { - setIsScanSucceeded(true); - setInterval(function () { - setIsScanSucceeded(false); - }, 500); - if (isSoundOn && scanSuccessAudioRef.current) { - scanSuccessAudioRef.current.play(); - } - } else { - setIsScanFailed(true); - setInterval(function () { - setIsScanFailed(false); - }, 500); - if (isSoundOn && scanErrorAudioRef.current) { - scanErrorAudioRef.current.play(); - } - } - }, () => { - setIsCheckingIn(false); - setCurrentAttendeeId(null); - } - ); + 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]); diff --git a/frontend/src/components/common/AttendeeDetails/index.tsx b/frontend/src/components/common/AttendeeDetails/index.tsx index 414bca7139..b54f882674 100644 --- a/frontend/src/components/common/AttendeeDetails/index.tsx +++ b/frontend/src/components/common/AttendeeDetails/index.tsx @@ -1,14 +1,13 @@ import {Anchor} from "@mantine/core"; -import {Card} from "../Card"; import {Attendee} from "../../../types.ts"; import classes from "./AttendeeDetails.module.scss"; import {t} from "@lingui/macro"; -import {getAttendeeTicketTitle} from "../../../utilites/tickets.ts"; +import {getAttendeeProductTitle} from "../../../utilites/products.ts"; import {getLocaleName, SupportedLocales} from "../../../locales.ts"; export const AttendeeDetails = ({attendee}: { attendee: Attendee }) => { return ( - +
{t`Name`} @@ -25,14 +24,6 @@ export const AttendeeDetails = ({attendee}: { attendee: Attendee }) => { {attendee.email}
-
-
- {t`Status`} -
-
- {attendee.status} -
-
{t`Checked In`} @@ -43,10 +34,10 @@ export const AttendeeDetails = ({attendee}: { attendee: Attendee }) => {
- {t`Ticket`} + {t`Product`}
- {getAttendeeTicketTitle(attendee)} + {getAttendeeProductTitle(attendee)}
@@ -57,6 +48,6 @@ export const AttendeeDetails = ({attendee}: { attendee: Attendee }) => { {getLocaleName(attendee.locale as SupportedLocales)}
- +
); } diff --git a/frontend/src/components/common/AttendeeList/AttendeeList.module.scss b/frontend/src/components/common/AttendeeList/AttendeeList.module.scss index 008a7faa8a..622146ad4c 100644 --- a/frontend/src/components/common/AttendeeList/AttendeeList.module.scss +++ b/frontend/src/components/common/AttendeeList/AttendeeList.module.scss @@ -1,35 +1,60 @@ +.container { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + .attendeeList { - margin-bottom: var(--tk-spacing-lg); - - .attendee { - display: flex; - padding: 10px; - border: 1px solid #dddddd; - border-radius: 5px; - margin-bottom: 10px; - align-items: center; - - &:last-of-type { - margin-bottom: 0; - } - - .attendeeName { - margin-left: 10px; - - .ticketName { - color: #9ca3af; - font-size: .8em; - } - } - - .viewAttendee { - flex: 1; - display: flex; - place-content: flex-end; - - a { - align-self: flex-end; - } - } + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.attendee { + display: flex; + align-items: center; + padding: 0.875rem; + background: var(--mantine-color-gray-0); + border: 1px solid var(--mantine-color-gray-2); + border-radius: var(--mantine-radius-sm); + transition: all 0.2s ease; + + &:hover { + border-color: var(--mantine-color-gray-3); + transform: translateY(-1px); } -} \ No newline at end of file +} + +.attendeeInfo { + display: flex; + align-items: center; + gap: 1rem; + flex: 1; +} + +.details { + flex: 1; + min-width: 0; +} + +.name { + line-height: 1.2; +} + +.product { + margin-top: 0.25rem; + color: var(--mantine-color-gray-6); +} + +.actionButton { + margin-left: auto; + + &:hover { + background-color: var(--mantine-color-blue-0); + } +} + +.avatar { + background: var(--tk-color-gray); + font-weight: 500; +} diff --git a/frontend/src/components/common/AttendeeList/index.tsx b/frontend/src/components/common/AttendeeList/index.tsx index a34f5f4304..b93a033d5f 100644 --- a/frontend/src/components/common/AttendeeList/index.tsx +++ b/frontend/src/components/common/AttendeeList/index.tsx @@ -1,38 +1,79 @@ -import {ActionIcon, Avatar, Tooltip} from "@mantine/core"; -import {getInitials} from "../../../utilites/helpers.ts"; -import Truncate from "../Truncate"; -import {NavLink} from "react-router-dom"; -import {IconEye} from "@tabler/icons-react"; +import { ActionIcon, Avatar, Tooltip, Text, Group } from "@mantine/core"; +import { getInitials } from "../../../utilites/helpers.ts"; +import { NavLink } from "react-router"; +import { IconExternalLink, IconUsers } from "@tabler/icons-react"; import classes from './AttendeeList.module.scss'; -import {Order, Ticket} from "../../../types.ts"; -import {t} from "@lingui/macro"; +import { Order, Product } from "../../../types.ts"; +import { t } from "@lingui/macro"; + +interface AttendeeListProps { + order: Order; + products: Product[]; +} + +export const AttendeeList = ({ order, products }: AttendeeListProps) => { + const attendeeCount = order.attendees?.length || 0; + + if (!order.attendees?.length) { + return ( +
+ + {t`No attendees found for this order.`} + +
+ ); + } -export const AttendeeList = ({order, tickets}: { order: Order, tickets: Ticket[] }) => { return ( -
- {order.attendees?.map(attendee => ( -
- - {getInitials(attendee.first_name + ' ' + attendee.last_name)} - +
+
+ {order.attendees.map(attendee => { + const product = products?.find(p => p.id === attendee.product_id); + const fullName = `${attendee.first_name} ${attendee.last_name}`; -
- {attendee.first_name + ' ' + attendee.last_name} -
- ticket.id === attendee.ticket_id)?.title}/> + return ( +
+
+ + {getInitials(fullName)} + + +
+ + {fullName} + + {product?.title && ( + + {product.title} + + )} +
+ + + + + + + + +
-
-
- - - - - - - -
-
- ))} + ); + })} +
- ) -} + ); +}; diff --git a/frontend/src/components/common/AttendeeStatusBadge/index.tsx b/frontend/src/components/common/AttendeeStatusBadge/index.tsx new file mode 100644 index 0000000000..24d1951d2c --- /dev/null +++ b/frontend/src/components/common/AttendeeStatusBadge/index.tsx @@ -0,0 +1,36 @@ +import {Attendee} from "../../../types.ts"; +import {Badge} from "@mantine/core"; + +interface AttendeeStatusBadgeProps { + attendee: Attendee; + noStyle?: boolean; +} + +export const AttendeeStatusBadge = ({attendee, noStyle = false}: AttendeeStatusBadgeProps) => { + let color; + + switch (attendee.status) { + case 'AWAITING_PAYMENT': + color = 'orange'; + break; + case 'CANCELLED': + color = 'red'; + break; + case 'ACTIVE': + default: + color = 'green'; + break; + } + + const status = attendee.status.replace('_', ' '); + + if (noStyle) { + return {status}; + } + + return ( + + {status} + + ); +}; diff --git a/frontend/src/components/common/AttendeeTable/AttendeeTable.module.scss b/frontend/src/components/common/AttendeeTable/AttendeeTable.module.scss new file mode 100644 index 0000000000..aabacfe59a --- /dev/null +++ b/frontend/src/components/common/AttendeeTable/AttendeeTable.module.scss @@ -0,0 +1,29 @@ +.tableContainer { + overflow-x: auto; +} + +.table { + width: 100%; + position: relative; +} + +.stickyActionColumn { + position: sticky; + right: 0; + background: white; + box-shadow: -4px 0 6px rgba(0, 0, 0, 0.1); + z-index: 2; +} + +.tableContainer::-webkit-scrollbar { + height: 10px; +} + +.tableContainer::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 5px; +} + +.tableContainer::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.05); +} diff --git a/frontend/src/components/common/AttendeeTable/index.tsx b/frontend/src/components/common/AttendeeTable/index.tsx index 5efc602cbb..df84da03fe 100644 --- a/frontend/src/components/common/AttendeeTable/index.tsx +++ b/frontend/src/components/common/AttendeeTable/index.tsx @@ -1,14 +1,13 @@ import {Anchor, Avatar, Badge, Button, Table as MantineTable,} from '@mantine/core'; import {Attendee, MessageType} from "../../../types.ts"; -import {IconEye, IconMailForward, IconPencil, IconPlus, IconSend, IconTrash} from "@tabler/icons-react"; -import {getInitials, getTicketFromEvent} from "../../../utilites/helpers.ts"; +import {IconMailForward, IconPlus, IconSend, IconTrash, IconUserCog} from "@tabler/icons-react"; +import {getInitials, getProductFromEvent} from "../../../utilites/helpers.ts"; import {Table, TableHead} from "../Table"; import {useDisclosure} from "@mantine/hooks"; import {SendMessageModal} from "../../modals/SendMessageModal"; import {useState} from "react"; import {NoResultsSplash} from "../NoResultsSplash"; -import {useParams} from "react-router-dom"; -import {EditAttendeeModal} from "../../modals/EditAttendeeModal"; +import {useParams} from "react-router"; import {useGetEvent} from "../../../queries/useGetEvent.ts"; import Truncate from "../Truncate"; import {notifications} from "@mantine/notifications"; @@ -17,8 +16,9 @@ import {showError, showSuccess} from "../../../utilites/notifications.tsx"; import {t, Trans} from "@lingui/macro"; import {confirmationDialog} from "../../../utilites/confirmationDialog.tsx"; import {useResendAttendeeTicket} from "../../../mutations/useResendAttendeeTicket.ts"; -import {ViewAttendeeModal} from "../../modals/ViewAttendeeModal"; -import {ActionMenu} from '../ActionMenu/index.tsx'; +import {ManageAttendeeModal} from "../../modals/ManageAttendeeModal"; +import {ActionMenu} from '../ActionMenu'; +import {AttendeeStatusBadge} from "../AttendeeStatusBadge"; interface AttendeeTableProps { attendees: Attendee[]; @@ -28,8 +28,7 @@ interface AttendeeTableProps { export const AttendeeTable = ({attendees, openCreateModal}: AttendeeTableProps) => { const {eventId} = useParams(); const [isMessageModalOpen, messageModal] = useDisclosure(false); - const [isEditModalOpen, editModal] = useDisclosure(false); - const [isViewModalOpem, viewModalOpen] = useDisclosure(false); + const [isViewModalOpen, viewModalOpen] = useDisclosure(false); const [selectedAttendee, setSelectedAttendee] = useState(); const {data: event} = useGetEvent(eventId); const modifyMutation = useModifyAttendee(); @@ -108,9 +107,9 @@ export const AttendeeTable = ({attendees, openCreateModal}: AttendeeTableProps) {t`Name`} {t`Email`} - {t`Order`} + {t`Order`} {t`Ticket`} - {t`Status`} + {t`Status`} @@ -146,14 +145,12 @@ export const AttendeeTable = ({attendees, openCreateModal}: AttendeeTableProps) - {attendee.status} + , + label: t`Manage attendee`, + icon: , onClick: () => handleModalClick(attendee, viewModalOpen), }, { @@ -170,11 +167,6 @@ export const AttendeeTable = ({attendees, openCreateModal}: AttendeeTableProps) icon: , onClick: () => handleModalClick(attendee, messageModal), }, - { - label: t`Edit attendee`, - icon: , - onClick: () => handleModalClick(attendee, editModal), - }, { label: t`Resend ticket email`, icon: , @@ -205,14 +197,9 @@ export const AttendeeTable = ({attendees, openCreateModal}: AttendeeTableProps) onClose={messageModal.close} orderId={selectedAttendee.order_id} attendeeId={selectedAttendee.id} - messageType={MessageType.Attendee} + messageType={MessageType.IndividualAttendees} />} - {(selectedAttendee?.id && isEditModalOpen) && } - - {(selectedAttendee?.id && isViewModalOpem) && } diff --git a/frontend/src/components/common/AttendeeTicket/AttendeeTicket.module.scss b/frontend/src/components/common/AttendeeTicket/AttendeeTicket.module.scss index 3dd8205026..9dcf458f7d 100644 --- a/frontend/src/components/common/AttendeeTicket/AttendeeTicket.module.scss +++ b/frontend/src/components/common/AttendeeTicket/AttendeeTicket.module.scss @@ -43,13 +43,13 @@ flex: 1; } - .ticketName { + .productName { font-size: 0.9em; font-weight: 900; margin-bottom: 5px; } - .ticketPrice { + .productPrice { .badge { background-color: #8BC34A; color: #fff; @@ -112,13 +112,23 @@ color: #d64646; width: 140px; } + + .awaitingPayment { + font-size: 1em; + display: flex; + justify-content: center; + align-items: center; + color: #e09300; + font-weight: 900; + margin-bottom: 10px; + } } - .ticketButtons { + .productButtons { background: #ffffff; border-radius: 5px; margin-top: 20px; border: 1px solid #d1d1d1; } } -} \ No newline at end of file +} diff --git a/frontend/src/components/common/AttendeeTicket/index.tsx b/frontend/src/components/common/AttendeeTicket/index.tsx index 823ca93246..622ac9b128 100644 --- a/frontend/src/components/common/AttendeeTicket/index.tsx +++ b/frontend/src/components/common/AttendeeTicket/index.tsx @@ -1,23 +1,23 @@ import {Card} from "../Card"; -import {getAttendeeTicketPrice, getAttendeeTicketTitle} from "../../../utilites/tickets.ts"; +import {getAttendeeProductPrice, getAttendeeProductTitle} from "../../../utilites/products.ts"; import {Anchor, Button, CopyButton} from "@mantine/core"; import {formatCurrency} from "../../../utilites/currency.ts"; import {t} from "@lingui/macro"; import {prettyDate} from "../../../utilites/dates.ts"; import QRCode from "react-qr-code"; import {IconCopy, IconPrinter} from "@tabler/icons-react"; -import {Attendee, Event, Ticket} from "../../../types.ts"; +import {Attendee, Event, Product} from "../../../types.ts"; import classes from './AttendeeTicket.module.scss'; interface AttendeeTicketProps { event: Event; attendee: Attendee; - ticket: Ticket; + product: Product; hideButtons?: boolean; } -export const AttendeeTicket = ({attendee, ticket, event, hideButtons = false}: AttendeeTicketProps) => { - const ticketPrice = getAttendeeTicketPrice(attendee, ticket); +export const AttendeeTicket = ({attendee, product, event, hideButtons = false}: AttendeeTicketProps) => { + const productPrice = getAttendeeProductPrice(attendee, product); return ( @@ -27,17 +27,17 @@ export const AttendeeTicket = ({attendee, ticket, event, hideButtons = false}: A

{attendee.first_name} {attendee.last_name}

-
- {getAttendeeTicketTitle(attendee)} +
+ {getAttendeeProductTitle(attendee)}
{attendee.email}
-
+
- {ticketPrice > 0 && formatCurrency(ticketPrice, event?.currency)} - {ticketPrice === 0 && t`Free`} + {productPrice > 0 && formatCurrency(productPrice, event?.currency)} + {productPrice === 0 && t`Free`}
@@ -61,20 +61,27 @@ export const AttendeeTicket = ({attendee, ticket, event, hideButtons = false}: A {t`Cancelled`}
)} + + {attendee.status === 'AWAITING_PAYMENT' && ( +
+ {t`Awaiting Payment`} +
+ )} {attendee.status !== 'CANCELLED' && } +
{!hideButtons && ( -
+
- + {({copied, copy}) => ( + ); +}; diff --git a/frontend/src/components/common/Editor/index.tsx b/frontend/src/components/common/Editor/index.tsx index 58289f8ce7..0c954cf420 100644 --- a/frontend/src/components/common/Editor/index.tsx +++ b/frontend/src/components/common/Editor/index.tsx @@ -15,8 +15,8 @@ import {ImageResize} from "./Extensions/ImageResizeExtension"; interface EditorProps { onChange: (value: string) => void; value: string; - label?: string; - description?: string; + label?: React.ReactNode; + description?: React.ReactNode; required?: boolean; className?: string; error?: string; diff --git a/frontend/src/components/common/ErrorDisplay/index.tsx b/frontend/src/components/common/ErrorDisplay/index.tsx index 08c9b5a687..e26d41fbaf 100644 --- a/frontend/src/components/common/ErrorDisplay/index.tsx +++ b/frontend/src/components/common/ErrorDisplay/index.tsx @@ -1,6 +1,6 @@ import {Container} from '@mantine/core'; import classes from './ErrorDisplay.module.scss'; -import {useRouteError} from "react-router-dom"; +import {useRouteError} from "react-router"; import {t} from "@lingui/macro"; export const ErrorDisplay = () => { diff --git a/frontend/src/components/common/EventCard/index.tsx b/frontend/src/components/common/EventCard/index.tsx index 1ca52bf21f..189384c3df 100644 --- a/frontend/src/components/common/EventCard/index.tsx +++ b/frontend/src/components/common/EventCard/index.tsx @@ -2,7 +2,7 @@ import {ActionIcon, Button,} from '@mantine/core'; import {Event, IdParam} from "../../../types.ts"; import classes from "./EventCard.module.scss"; import {Card} from "../Card"; -import {NavLink, useNavigate} from "react-router-dom"; +import {NavLink, useNavigate} from "react-router"; import { IconArchive, IconCash, @@ -93,7 +93,7 @@ export function EventCard({event}: EventCardProps) {
{event && }
- + {event.title}
@@ -123,7 +123,7 @@ export function EventCard({event}: EventCardProps) {
- {formatNumber(event?.statistics?.tickets_sold || 0)} {t`tickets sold`} + {formatNumber(event?.statistics?.products_sold || 0)} {t`products sold`}
diff --git a/frontend/src/components/common/EventDocumentHead/index.tsx b/frontend/src/components/common/EventDocumentHead/index.tsx index 2c693d942a..7bb2bacd07 100644 --- a/frontend/src/components/common/EventDocumentHead/index.tsx +++ b/frontend/src/components/common/EventDocumentHead/index.tsx @@ -10,6 +10,7 @@ interface EventDocumentHeadProps { export const EventDocumentHead = ({event}: EventDocumentHeadProps) => { const eventSettings = event.settings; + const products = event.product_categories?.flatMap(category => category.products) ?? []; const title = (eventSettings?.seo_title ?? event.title) + ' | ' + event.organizer?.name; const description = eventSettings?.seo_description ?? event.description_preview; const keywords = eventSettings?.seo_keywords; @@ -57,13 +58,13 @@ export const EventDocumentHead = ({event}: EventDocumentHeadProps) => { eventStatus: 'https://schema.org/EventScheduled', eventAttendanceMode: event.settings?.is_online_event ? "https://schema.org/OnlineEventAttendanceMode" : "https://schema.org/OfflineEventAttendanceMode", currency: event.currency, - offers: event.tickets?.map(ticket => ({ + offers: products.map(product => ({ "@type": "http://schema.org/Offer", url, - price: ticket.prices?.[0]?.price, + price: product?.prices?.[0]?.price, priceCurrency: event.currency, validFrom: startDate, - availability: ticket.is_available ? "http://schema.org/InStock" : "http://schema.org/SoldOut", + availability: product?.is_available ? "http://schema.org/InStock" : "http://schema.org/SoldOut", })), }; diff --git a/frontend/src/components/common/EventsDashboardStatusButtons/index.tsx b/frontend/src/components/common/EventsDashboardStatusButtons/index.tsx index 2ad171a0ea..ae10fb6ec0 100644 --- a/frontend/src/components/common/EventsDashboardStatusButtons/index.tsx +++ b/frontend/src/components/common/EventsDashboardStatusButtons/index.tsx @@ -1,6 +1,6 @@ import {Button, Group} from "@mantine/core"; import {t} from "@lingui/macro"; -import {useNavigate} from "react-router-dom"; +import {useNavigate} from "react-router"; interface EventsDashboardStatusButtonsProps { baseUrl: string; diff --git a/frontend/src/components/common/FilterModal/index.tsx b/frontend/src/components/common/FilterModal/index.tsx new file mode 100644 index 0000000000..112e85490d --- /dev/null +++ b/frontend/src/components/common/FilterModal/index.tsx @@ -0,0 +1,260 @@ +import React from 'react'; +import {Button, Group, Modal, MultiSelect, Stack, Text, TextInput} from '@mantine/core'; +import {useDisclosure} from '@mantine/hooks'; +import {IconFilter} from '@tabler/icons-react'; +import {t} from '@lingui/macro'; + +export interface FilterOption { + field: string; + label: string; + type: 'multi-select' | 'date-range' | 'single-select' | 'text'; + options?: { label: string; value: string }[]; +} + +interface FilterValues { + [key: string]: any; +} + +interface FilterModalProps { + filters: FilterOption[]; + activeFilters: FilterValues; + onChange: (values: FilterValues) => void; + onReset?: () => void; + title?: string; +} + +const normalizeFilterValue = (value: any, type: string): any => { + if (value === undefined || value === null) { + return []; + } + + switch (type) { + case 'multi-select': { + if (Array.isArray(value)) { + return value; + } + + if (typeof value === 'string') { + return value.split(',').filter(Boolean).map(item => item.trim()); + } + + if (value?.value) { + if (Array.isArray(value.value)) { + return value.value; + } + return normalizeFilterValue(value.value, type); + } + + return []; + } + + case 'text': { + return value; + } + + default: { + return value; + } + } +}; + +const normalizeFilters = (filters: FilterOption[], values: FilterValues): FilterValues => { + return filters.reduce((acc, filter) => { + return { + ...acc, + [filter.field]: normalizeFilterValue(values[filter.field], filter.type) + }; + }, {}); +}; + +export const FilterModal: React.FC = ({ + filters, + activeFilters, + onChange, + onReset, + title = t`Filters` + }) => { + const [opened, {open, close}] = useDisclosure(false); + const [localFilters, setLocalFilters] = React.useState(() => { + return normalizeFilters(filters, activeFilters); + }); + + React.useEffect(() => { + if (!opened) { + setLocalFilters(normalizeFilters(filters, activeFilters)); + } + }, [activeFilters, filters, opened]); + + const handleSave = () => { + onChange(localFilters); + close(); + }; + + const handleReset = () => { + const emptyFilters = filters.reduce((acc, filter) => { + let emptyValue; + + switch (filter.type) { + case 'multi-select': { + emptyValue = []; + break; + } + case 'text': { + emptyValue = ''; + break; + } + case 'single-select': { + emptyValue = null; + break; + } + case 'date-range': { + emptyValue = {start: null, end: null}; + break; + } + default: { + emptyValue = null; + } + } + + return { + ...acc, + [filter.field]: emptyValue + }; + }, {}); + + if (onReset) { + onReset(); + } + + setLocalFilters(emptyFilters); + onChange(emptyFilters); + close(); + }; + + const handleFilterChange = (field: string, value: any) => { + setLocalFilters(prev => { + return { + ...prev, + [field]: value, + }; + }); + }; + + const renderFilterInput = (filter: FilterOption) => { + const normalizedValue = normalizeFilterValue(localFilters[filter.field], filter.type); + + switch (filter.type) { + case 'multi-select': { + return ( + { + handleFilterChange(filter.field, value || []); + }} + clearable + searchable + w="100%" + style={{marginBottom: 0}} + /> + ); + } + + case 'text': { + return ( + { + handleFilterChange(filter.field, event.currentTarget.value); + }} + w="100%" + /> + ); + } + + default: { + return null; + } + } + }; + + const countActiveFilters = (filterValues: FilterValues, filterOptions: FilterOption[]): number => { + return Object.entries(filterValues).reduce((count, [field, value]) => { + const filterOption = filterOptions.find(f => f.field === field); + + if (!filterOption) { + return count; + } + + const normalizedValue = normalizeFilterValue(value, filterOption.type); + + if (Array.isArray(normalizedValue)) { + if (normalizedValue.length > 0) { + return count + 1; + } + return count; + } + + if (normalizedValue) { + return count + 1; + } + + return count; + }, 0); + }; + + const activeFilterCount = countActiveFilters(activeFilters, filters); + const hasActiveFilters = activeFilterCount > 0; + + return ( + <> + + + + + {filters.length === 0 ? ( + + {t`No filters available`} + + ) : ( + filters.map(filter => { + return ( +
+ {renderFilterInput(filter)} +
+ ); + }) + )} + + + + + +
+
+ + ); +}; diff --git a/frontend/src/components/common/GlobalMenu/index.tsx b/frontend/src/components/common/GlobalMenu/index.tsx index c5ca740b1a..4c26d723c9 100644 --- a/frontend/src/components/common/GlobalMenu/index.tsx +++ b/frontend/src/components/common/GlobalMenu/index.tsx @@ -2,7 +2,7 @@ import {Avatar, Menu, UnstyledButton} from "@mantine/core"; import {getInitials} from "../../../utilites/helpers.ts"; import {IconLifebuoy, IconLogout, IconSettingsCog, IconUser,} from "@tabler/icons-react"; import {useGetMe} from "../../../queries/useGetMe.ts"; -import {NavLink} from "react-router-dom"; +import {NavLink} from "react-router"; import {t} from "@lingui/macro"; import {authClient} from "../../../api/auth.client.ts"; import {useDisclosure} from "@mantine/hooks"; diff --git a/frontend/src/components/common/Header/index.tsx b/frontend/src/components/common/Header/index.tsx index f045f5ff3e..95c7373d04 100644 --- a/frontend/src/components/common/Header/index.tsx +++ b/frontend/src/components/common/Header/index.tsx @@ -1,6 +1,6 @@ import {Container} from '@mantine/core'; import classes from './Header.module.scss'; -import {NavLink} from "react-router-dom"; +import {NavLink} from "react-router"; interface HeaderProps { rightContent?: React.ReactNode; diff --git a/frontend/src/components/common/HomepageInfoMessage/index.tsx b/frontend/src/components/common/HomepageInfoMessage/index.tsx index 97e20b7248..8e29937c10 100644 --- a/frontend/src/components/common/HomepageInfoMessage/index.tsx +++ b/frontend/src/components/common/HomepageInfoMessage/index.tsx @@ -33,4 +33,4 @@ export const HomepageInfoMessage = ({message, link, linkText, iconType = 'info'} )}
); -} \ No newline at end of file +} diff --git a/frontend/src/components/common/LanguageSwitcher/index.tsx b/frontend/src/components/common/LanguageSwitcher/index.tsx index fecf4d6d9e..35d542ed84 100644 --- a/frontend/src/components/common/LanguageSwitcher/index.tsx +++ b/frontend/src/components/common/LanguageSwitcher/index.tsx @@ -24,6 +24,8 @@ export const LanguageSwitcher = () => { return t`Brazilian Portuguese`; case "zh-cn": return t`Chinese (Simplified)`; + case "vi": + return t`Vietnamese`; } }; @@ -49,4 +51,4 @@ export const LanguageSwitcher = () => { /> ) -} \ No newline at end of file +} diff --git a/frontend/src/components/common/MessageList/index.tsx b/frontend/src/components/common/MessageList/index.tsx index f45008f27c..88c10a41f8 100644 --- a/frontend/src/components/common/MessageList/index.tsx +++ b/frontend/src/components/common/MessageList/index.tsx @@ -1,4 +1,4 @@ -import {Message} from "../../../types.ts"; +import {Message, MessageType} from "../../../types.ts"; import classes from './MessageList.module.scss'; import {relativeDate} from "../../../utilites/dates.ts"; import {Card} from "../Card"; @@ -15,6 +15,14 @@ interface MessageListProps { const SingleMessage = ({message}: { message: Message }) => { const [showFullMessage, setShowFullMessage] = useState(false); + const typeToDescription = { + [MessageType.OrderOwnersWithProduct]: t`Order owners with products`, + [MessageType.IndividualAttendees]: t`Individual attendees`, + [MessageType.AllAttendees]: t`All attendees`, + [MessageType.TicketHolders]: t`Ticket holders`, + [MessageType.OrderOwner]: t`Order owner`, + } + return (
@@ -36,7 +44,9 @@ const SingleMessage = ({message}: { message: Message }) => {
{message.subject}
-
{message.type}
+
+ {typeToDescription[message.type]} +
{showFullMessage ?
@@ -61,7 +71,7 @@ export const MessageList = ({messages}: MessageListProps) => { subHeading={( <>

- {t`You haven't sent any messages yet. You can send messages to all attendees, or to specific ticket holders.`} + {t`You haven't sent any messages yet. You can send messages to all attendees, or to specific product holders.`}

)} @@ -76,4 +86,4 @@ export const MessageList = ({messages}: MessageListProps) => { })}
) -} \ No newline at end of file +} diff --git a/frontend/src/components/common/NoResultsSplash/index.tsx b/frontend/src/components/common/NoResultsSplash/index.tsx index 05109a93a3..89646a2f5c 100644 --- a/frontend/src/components/common/NoResultsSplash/index.tsx +++ b/frontend/src/components/common/NoResultsSplash/index.tsx @@ -1,6 +1,6 @@ import React from "react"; import classes from './NoResultsSplash.module.scss'; -import {useSearchParams} from "react-router-dom"; +import {useSearchParams} from "react-router"; import {t} from "@lingui/macro"; interface NoResultsSplashProps { diff --git a/frontend/src/components/common/NumberSelector/index.tsx b/frontend/src/components/common/NumberSelector/index.tsx index 4c795dbb05..579e365816 100644 --- a/frontend/src/components/common/NumberSelector/index.tsx +++ b/frontend/src/components/common/NumberSelector/index.tsx @@ -29,12 +29,12 @@ export const NumberSelector = ({formInstance, fieldName, min, max, sharedValues} }, [value]); useEffect(() => { - // to handle application promo code after updating the quanity + // to handle application promo code after updating the quantity const formValue = _.get(formInstance.values, fieldName) if (formValue !== value) { formInstance.setFieldValue(fieldName, value); } - }, [formInstance]); + }, [formInstance.values]); const increment = () => { // Adjust from 0 to minValue on the first increment, if minValue is greater than 0 diff --git a/frontend/src/components/common/OnlineEventDetails/index.tsx b/frontend/src/components/common/OnlineEventDetails/index.tsx index d40a1b92e3..b428316cc4 100644 --- a/frontend/src/components/common/OnlineEventDetails/index.tsx +++ b/frontend/src/components/common/OnlineEventDetails/index.tsx @@ -5,7 +5,7 @@ import {EventSettings} from "../../../types.ts"; export const OnlineEventDetails = (props: { eventSettings: EventSettings }) => { return <> {(props.eventSettings.is_online_event && props.eventSettings.online_event_connection_details) && ( -
+

{t`Online Event Details`}

{ +export const OrderDetails = ({order, event, cardVariant = 'lightGray', style = {}}: { + order: Order, + event: Event, + cardVariant?: CardVariant, + style?: React.CSSProperties +}) => { return ( - +
{t`Name`} @@ -43,7 +51,7 @@ export const OrderDetails = ({order, event}: { order: Order, event: Event }) => {t`Status`}
- +
@@ -62,6 +70,36 @@ export const OrderDetails = ({order, event}: { order: Order, event: Event }) =>
+ {order.payment_provider && ( +
+
+ {t`Payment provider`} +
+
+ {capitalize(order.payment_provider)} +
+
+ )} + {order.promo_code && ( +
+
+ {t`Promo code`} +
+
+ {order.promo_code} +
+
+ )} + {order.address && ( +
+
+ {t`Address`} +
+
+ {formatAddress(order.address)} +
+
+ )}
); -} \ No newline at end of file +} diff --git a/frontend/src/components/common/OrderStatusBadge/index.tsx b/frontend/src/components/common/OrderStatusBadge/index.tsx index b384b2b1f9..a5d4ba4665 100644 --- a/frontend/src/components/common/OrderStatusBadge/index.tsx +++ b/frontend/src/components/common/OrderStatusBadge/index.tsx @@ -1,12 +1,19 @@ import {Badge, BadgeVariant} from "@mantine/core"; import {Order} from "../../../types.ts"; import {getStatusColor} from "../../../utilites/helpers.ts"; +import {t} from "@lingui/macro"; export const OrderStatusBadge = ({order, variant = 'outline'}: { order: Order, variant?: BadgeVariant }) => { let color; let title; - if (order.refund_status) { + if (order.status === 'CANCELLED') { + color = getStatusColor(order.status); + title = t`Cancelled`; + } else if (order.status === 'AWAITING_OFFLINE_PAYMENT') { + color = getStatusColor('AWAITING_PAYMENT'); + title = t`Awaiting offline payment`; + } else if (order.refund_status) { color = getStatusColor(order.refund_status); title = order.refund_status; } else if (order.payment_status && order.payment_status !== 'PAYMENT_RECEIVED' @@ -19,4 +26,4 @@ export const OrderStatusBadge = ({order, variant = 'outline'}: { order: Order, v } return {title.replace('_', ' ')} -}; \ No newline at end of file +}; diff --git a/frontend/src/components/common/OrdersTable/index.tsx b/frontend/src/components/common/OrdersTable/index.tsx index 6aaef168e0..837994060c 100644 --- a/frontend/src/components/common/OrdersTable/index.tsx +++ b/frontend/src/components/common/OrdersTable/index.tsx @@ -1,18 +1,20 @@ import {t} from "@lingui/macro"; import {Anchor, Badge, Button, Group, Menu, Table as MantineTable, Tooltip} from '@mantine/core'; -import {Event, IdParam, MessageType, Order} from "../../../types.ts"; +import {Event, IdParam, Invoice, MessageType, Order} from "../../../types.ts"; import { + IconBasketCog, IconCheck, IconDotsVertical, - IconEye, IconInfoCircle, + IconReceipt2, + IconReceiptDollar, IconReceiptRefund, IconRepeat, IconSend, IconTrash } from "@tabler/icons-react"; import {prettyDate, relativeDate} from "../../../utilites/dates.ts"; -import {ViewOrderModal} from "../../modals/ViewOrderModal"; +import {ManageOrderModal} from "../../modals/ManageOrderModal"; import {useDisclosure} from "@mantine/hooks"; import {useState} from "react"; import {CancelOrderModal} from "../../modals/CancelOrderModal"; @@ -29,6 +31,11 @@ import {useResendOrderConfirmation} from "../../../mutations/useResendOrderConfi import {OrderStatusBadge} from "../OrderStatusBadge"; import {formatNumber} from "../../../utilites/helpers.ts"; import {useUrlHash} from "../../../hooks/useUrlHash.ts"; +import {useMarkOrderAsPaid} from "../../../mutations/useMarkOrderAsPaid.ts"; +import {orderClient} from "../../../api/order.client.ts"; +import {downloadBinary} from "../../../utilites/download.ts"; +import {withLoadingNotification} from "../../../utilites/withLoadingNotification.tsx"; +import {showError, showSuccess} from "../../../utilites/notifications.tsx"; interface OrdersTableProps { event: Event, @@ -42,6 +49,7 @@ export const OrdersTable = ({orders, event}: OrdersTableProps) => { const [isRefundModalOpen, refundModal] = useDisclosure(false); const [orderId, setOrderId] = useState(); const resendConfirmationMutation = useResendOrderConfirmation(); + const markAsPaidMutation = useMarkOrderAsPaid(); useUrlHash(/^#order-(\d+)$/, (matches => { const orderId = matches![1]; @@ -66,24 +74,61 @@ export const OrdersTable = ({orders, event}: OrdersTableProps) => { modal.open(); } + const handleMarkAsPaid = (eventId: IdParam, orderId: IdParam) => { + markAsPaidMutation.mutate({eventId, orderId}, { + onSuccess: () => showSuccess(t`Order marked as paid`), + onError: () => showError(t`There was an error marking the order as paid`) + }); + } + const handleResendConfirmation = (eventId: IdParam, orderId: IdParam) => { resendConfirmationMutation.mutate({eventId, orderId}, { onSuccess: () => { notifications.show({ message: t`Your message has been sent`, - icon: + icon: , + position: 'top-center', }) }, onError: () => { notifications.show({ message: t`There was an error sending your message`, - icon: + icon: , + position: 'top-center', }) } }); } + const handleInvoiceDownload = async (invoice: Invoice) => { + await withLoadingNotification( + async () => { + const blob = await orderClient.downloadInvoice(event.id, invoice.order_id); + downloadBinary(blob, invoice.invoice_number + '.pdf'); + }, + { + loading: { + title: t`Downloading Invoice`, + message: t`Please wait while we prepare your invoice...` + }, + success: { + title: t`Success`, + message: t`Invoice downloaded successfully` + }, + error: { + title: t`Error`, + message: t`Failed to download invoice. Please try again.` + } + } + ); + }; + const ActionMenu = ({order}: { order: Order }) => { + const isRefundable = !order.is_free_order + && order.status !== 'AWAITING_OFFLINE_PAYMENT' + && order.payment_provider === 'STRIPE' + && order.refund_status !== 'REFUNDED'; + return @@ -108,11 +153,21 @@ export const OrdersTable = ({orders, event}: OrdersTableProps) => { {t`Manage`} handleModalClick(order.id, viewModal)} - leftSection={}>{t`View order`} + leftSection={}>{t`Manage order`} handleModalClick(order.id, messageModal)} leftSection={}>{t`Message buyer`} - {!order.is_free_order && ( + {order.latest_invoice && ( + handleInvoiceDownload(order.latest_invoice as Invoice)} + leftSection={}>{t`Download invoice`} + )} + + {order.status === 'AWAITING_OFFLINE_PAYMENT' && ( + handleMarkAsPaid(event.id, order.id)} + leftSection={}>{t`Mark as paid`} + )} + + {isRefundable && ( handleModalClick(order.id, refundModal)} leftSection={}>{t`Refund order`} )} @@ -146,12 +201,12 @@ export const OrdersTable = ({orders, event}: OrdersTableProps) => { - {t`Order #`} + {t`Reference`} {t`Customer`} {t`Attendees`} - {t`Amount`} + {t`Amount`} {t`Created`} - {t`Status`} + {t`Status`} @@ -249,12 +304,12 @@ export const OrdersTable = ({orders, event}: OrdersTableProps) => { {orderId && ( <> {isRefundModalOpen && } - {isViewModalOpen && } + {isViewModalOpen && } {isCancelModalOpen && } {isMessageModalOpen && } )} diff --git a/frontend/src/components/common/PageTitle/PageTitle.module.scss b/frontend/src/components/common/PageTitle/PageTitle.module.scss index 44e282b386..8f9c920d0b 100644 --- a/frontend/src/components/common/PageTitle/PageTitle.module.scss +++ b/frontend/src/components/common/PageTitle/PageTitle.module.scss @@ -1,10 +1,22 @@ @import "../../../styles/mixins.scss"; +.container { + margin-bottom: var(--tk-spacing-md); +} + .title { - margin-bottom: var(--tk-spacing-md) !important; + margin-bottom: 0.5rem !important; margin-top: 0; @include respond-below(md) { font-size: 25px !important; } -} \ No newline at end of file +} + +.subheading { + font-size: 1rem; + color: var(--tk-color-text-muted, #6c757d); + font-weight: normal; + line-height: 1.4; + margin-bottom: 20px; +} diff --git a/frontend/src/components/common/PageTitle/index.tsx b/frontend/src/components/common/PageTitle/index.tsx index 1c3db1657f..71c1b0b8a3 100644 --- a/frontend/src/components/common/PageTitle/index.tsx +++ b/frontend/src/components/common/PageTitle/index.tsx @@ -1,12 +1,24 @@ import React from "react"; import classes from './PageTitle.module.scss'; -interface PageTitleProps { +interface PageTitleProps extends React.HTMLAttributes { children: React.ReactNode, + subheading?: string } -export const PageTitle = ({children}: PageTitleProps) => { +export const PageTitle = (props: PageTitleProps) => { + const { children, subheading, ...rest } = props; + return ( -

{children}

+
+

+ {children} +

+ {subheading && ( +
+ {subheading} +
+ )} +
); } diff --git a/frontend/src/components/common/PoweredByFooter/index.tsx b/frontend/src/components/common/PoweredByFooter/index.tsx index c3b3693eb8..f2e95c4bc6 100644 --- a/frontend/src/components/common/PoweredByFooter/index.tsx +++ b/frontend/src/components/common/PoweredByFooter/index.tsx @@ -5,7 +5,7 @@ import React from "react"; import {iHavePurchasedALicence} from "../../../utilites/helpers.ts"; /** - * (c) Hi.Events Ltd 2024 + * (c) Hi.Events Ltd 2025 * * PLEASE NOTE: * @@ -29,7 +29,7 @@ export const PoweredByFooter = (props: React.DetailedHTMLProps + title={'Effortlessly manage events and sell products online with Hi.Events'}> Hi.Events 🚀 diff --git a/frontend/src/components/common/TicketPriceAvailability/index.tsx b/frontend/src/components/common/ProductPriceAvailability/index.tsx similarity index 51% rename from frontend/src/components/common/TicketPriceAvailability/index.tsx rename to frontend/src/components/common/ProductPriceAvailability/index.tsx index c5b84762b7..b32f1ece00 100644 --- a/frontend/src/components/common/TicketPriceAvailability/index.tsx +++ b/frontend/src/components/common/ProductPriceAvailability/index.tsx @@ -1,10 +1,10 @@ -import {Event, Ticket, TicketPrice} from "../../../types.ts"; +import {Event, Product, ProductPrice} from "../../../types.ts"; import {t} from "@lingui/macro"; import {Tooltip} from "@mantine/core"; import {prettyDate, relativeDate} from "../../../utilites/dates.ts"; import {IconInfoCircle} from "@tabler/icons-react"; -const TicketPriceSaleDateMessage = ({price, event}: { price: TicketPrice, event: Event }) => { +const ProductPriceSaleDateMessage = ({price, event}: { price: ProductPrice, event: Event }) => { if (price.is_sold_out) { return t`Sold out`; } @@ -27,19 +27,19 @@ const TicketPriceSaleDateMessage = ({price, event}: { price: TicketPrice, event: return t`Not available`; } -export const TicketAvailabilityMessage = ({ticket, event}: { ticket: Ticket, event: Event }) => { - if (ticket.is_sold_out) { +export const ProductAvailabilityMessage = ({product, event}: { product: Product, event: Event }) => { + if (product.is_sold_out) { return t`Sold out`; } - if (ticket.is_after_sale_end_date) { + if (product.is_after_sale_end_date) { return t`Sales ended`; } - if (ticket.is_before_sale_start_date) { + if (product.is_before_sale_start_date) { return ( {t`Sales start`}{' '} - - {relativeDate(String(ticket.sale_start_date))}{' '} + + {relativeDate(String(product.sale_start_date))}{' '} ); @@ -48,17 +48,17 @@ export const TicketAvailabilityMessage = ({ticket, event}: { ticket: Ticket, eve return t`Not available`; } -interface TicketAndPriceAvailabilityProps { - ticket: Ticket; - price: TicketPrice; +interface ProductAndPriceAvailabilityProps { + product: Product; + price: ProductPrice; event: Event; } -export const TicketPriceAvailability = ({ticket, price, event}: TicketAndPriceAvailabilityProps) => { +export const ProductPriceAvailability = ({product, price, event}: ProductAndPriceAvailabilityProps) => { - if (ticket.type === 'TIERED') { - return + if (product.type === 'TIERED') { + return } - return + return } diff --git a/frontend/src/components/common/ProductSelector/index.tsx b/frontend/src/components/common/ProductSelector/index.tsx new file mode 100644 index 0000000000..5dfb667210 --- /dev/null +++ b/frontend/src/components/common/ProductSelector/index.tsx @@ -0,0 +1,109 @@ +import {MultiSelect, Select} from "@mantine/core"; +import {IconTicket} from "@tabler/icons-react"; +import {UseFormReturnType} from "@mantine/form"; +import {ProductCategory, ProductType} from "../../../types.ts"; +import React from "react"; +import {t} from "@lingui/macro"; + +interface ProductSelectorProps { + label: string; + placeholder: string; + icon?: React.ReactNode; + productCategories: ProductCategory[]; + form: UseFormReturnType; + productFieldName: string; + tierFieldName?: string; + includedProductTypes?: ProductType[]; + multiSelect?: boolean; + showTierSelector?: boolean; + noProductsMessage?: string; +} + +export const ProductSelector = ({ + label, + placeholder, + icon = , + productCategories, + form, + productFieldName, + tierFieldName = 'product_price_id', + includedProductTypes = [ProductType.Ticket, ProductType.General], + multiSelect = true, + showTierSelector = false, + noProductsMessage = t`No products available for selection`, + }: ProductSelectorProps) => { + const formattedData = productCategories?.map((category) => ({ + group: category.name, + items: + category.products + ?.filter((product) => includedProductTypes.includes(product.product_type)) + ?.map((product) => ({ + value: String(product.id), + label: product.title, + })) || [], + })); + const eventProducts = productCategories?.flatMap(category => category.products).filter(product => product !== undefined); + + if (!eventProducts || eventProducts.length === 0) { + return ( + product.id == form.values.product_id)?.prices?.map(price => { + return { + value: String(price.id), + label: String(price.label), + }; + })} + /> + )} + + ); + } + + if (multiSelect) { + return ( + <> + + {showTierSelector && } + + + ); + } else { + return ( + <> + } + mb="0" + className={classes.periodSelect} + /> + )} + {showDateFilter && showDatePickerInput && ( + } + type="range" + placeholder="Pick dates range" + value={dateRange} + onChange={handleDateRangeChange} + minDate={dayjs().subtract(1, 'year').tz(event.timezone).toDate()} + maxDate={dayjs().tz(event.timezone).toDate()} + className={classes.datePicker} + /> + )} + {enableDownload && ( + + )} + + +
+ + + {columns.map((column) => ( + handleSort(column.key) : undefined} + style={{cursor: column.sortable ? 'pointer' : 'default', minWidth: '180px'}} + > + + {t`${column.label}`} + {column.sortable && getSortIcon(column.key)} + + + ))} + + + + {!sortedData.length && loadingMessage()} + {sortedData.map((row, index) => ( + + {columns.map((column) => ( + + {column.render + ? column.render(row[column.key], row) + : row[column.key] + } + + ))} + + ))} + +
+ + ); +}; + +export default ReportTable; diff --git a/frontend/src/components/common/SearchBar/SearchBar.module.scss b/frontend/src/components/common/SearchBar/SearchBar.module.scss index e322b9b566..0849630f78 100644 --- a/frontend/src/components/common/SearchBar/SearchBar.module.scss +++ b/frontend/src/components/common/SearchBar/SearchBar.module.scss @@ -5,7 +5,6 @@ align-items: center; gap: 10px; - @include respond-below(sm, true) { flex-direction: column; } diff --git a/frontend/src/components/common/ShareIcon/index.tsx b/frontend/src/components/common/ShareIcon/index.tsx index b73d4bc38f..94de09d7f5 100644 --- a/frontend/src/components/common/ShareIcon/index.tsx +++ b/frontend/src/components/common/ShareIcon/index.tsx @@ -1,12 +1,13 @@ import {useState} from 'react'; -import {ActionIcon, CopyButton, Group, Input, Popover, Button} from '@mantine/core'; +import {ActionIcon, Button, CopyButton, Group, Input, Popover} from '@mantine/core'; import { IconBrandFacebook, IconBrandLinkedin, IconBrandTwitter, IconBrandWhatsapp, IconCheck, - IconCopy, IconMail, + IconCopy, + IconMail, IconShare } from "@tabler/icons-react"; import {t} from "@lingui/macro"; @@ -16,9 +17,17 @@ interface ShareComponentProps { text: string; url: string; imageUrl?: string; + shareButtonText?: string; + hideShareButtonText?: boolean; } -export const ShareComponent = ({title, text, url}: ShareComponentProps) => { +export const ShareComponent = ({ + title, + text, + url, + shareButtonText = t`Share`, + hideShareButtonText = false, + }: ShareComponentProps) => { const [opened, setOpened] = useState(false); let shareText = text; @@ -49,12 +58,21 @@ export const ShareComponent = ({title, text, url}: ShareComponentProps) => { withArrow > - +
+ {hideShareButtonText && ( + + + + )} + + {!hideShareButtonText && ( + )} +
- + { - {({ copied, copy }) => ( + {({copied, copy}) => ( {copied ? : } diff --git a/frontend/src/components/common/SideDrawer/SideDrawer.module.scss b/frontend/src/components/common/SideDrawer/SideDrawer.module.scss new file mode 100644 index 0000000000..a1174a020c --- /dev/null +++ b/frontend/src/components/common/SideDrawer/SideDrawer.module.scss @@ -0,0 +1,6 @@ +.sideBarTitle { + font-size: 1.5rem; + font-weight: 600; + margin-top: 15px; + margin-bottom: 5px; +} diff --git a/frontend/src/components/common/SideDrawer/index.tsx b/frontend/src/components/common/SideDrawer/index.tsx new file mode 100644 index 0000000000..5bd54d1963 --- /dev/null +++ b/frontend/src/components/common/SideDrawer/index.tsx @@ -0,0 +1,31 @@ +import {Drawer, DrawerProps} from "@mantine/core"; +import React from "react"; +import classes from "./SideDrawer.module.scss"; + +interface SideDrawerProps { + heading?: string | React.ReactNode, +} + +export const SideDrawer = (props: DrawerProps & SideDrawerProps) => { + return ( + +
+ {props.children} +
+
+ ) +} diff --git a/frontend/src/components/common/SortArrows/index.tsx b/frontend/src/components/common/SortArrows/index.tsx new file mode 100644 index 0000000000..fe0dbdce38 --- /dev/null +++ b/frontend/src/components/common/SortArrows/index.tsx @@ -0,0 +1,43 @@ +import {ActionIcon} from '@mantine/core'; +import {IconCaretDownFilled, IconCaretUpFilled} from '@tabler/icons-react'; + +interface SortArrowsProps { + upArrowEnabled: boolean; + downArrowEnabled: boolean; + onSortUp: () => void; + onSortDown: () => void; + flexDirection?: 'row' | 'column'; +} + +export const SortArrows = ({ + upArrowEnabled, + downArrowEnabled, + onSortUp, + onSortDown, + flexDirection = 'row' + }: SortArrowsProps) => { + return ( +
+ + + + + + +
+ ); +}; diff --git a/frontend/src/components/common/StatBoxes/StatBoxes.module.scss b/frontend/src/components/common/StatBoxes/StatBoxes.module.scss index 7abeb018e0..c6aff0b007 100644 --- a/frontend/src/components/common/StatBoxes/StatBoxes.module.scss +++ b/frontend/src/components/common/StatBoxes/StatBoxes.module.scss @@ -1,57 +1,100 @@ +@import "../../../styles/mixins.scss"; + .statistics { - display: flex; - justify-content: space-around; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; color: var(--tk-primary); - gap: 15px; + margin: 0.5rem 0; + + @include respond-below(lg) { + grid-template-columns: repeat(2, 1fr); + } + + @include respond-below(md) { + grid-template-columns: 1fr; + } } .statistic { - flex: 1 1 50px; display: flex; - flex-direction: row; - padding: 20px; - min-width: 250px; - margin-bottom: 0; + align-items: center; + padding: 1.25rem !important; + transition: all 0.2s ease-in-out; + margin-bottom: 0 !important; + height: 100%; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + } .leftPanel { - display: flex; - flex-direction: column; - gap: 10px; + flex: 1; + display: grid; + grid-template-rows: auto auto; + min-height: 65px; + gap: 5px; .number { - font-size: 1.5em; - font-weight: bold; + font-size: 1.75rem; + font-weight: 600; + letter-spacing: -0.02em; + line-height: 1.2; + align-self: end; + + @include respond-below(md) { + font-size: 1.5rem; + } } .description { - font-size: 1em; + font-size: 1rem; color: var(--tk-color-gray-dark); - } - - .change { - font-size: 1em; + font-weight: 500; + align-self: start; } } .rightPanel { - flex: 1; - display: flex; - place-content: flex-end; - align-items: center; - + margin-left: 1rem; + align-self: flex-start; .icon { - width: 40px; - height: 40px; - background: #63d57e; - border-radius: 50px; + width: 42px; + height: 42px; + border-radius: 12px; display: flex; align-items: center; justify-content: center; color: #ffffff; + transition: transform 0.2s ease; + + @include respond-below(md) { + width: 36px; + height: 36px; + border-radius: 10px; + } + + &:hover { + transform: scale(1.05); + } } } -} + @include respond-below(sm) { + padding: 1rem !important; + .leftPanel { + min-height: 55px; + + .number { + font-size: 1.25rem; + } + + .description { + font-size: 0.9125rem; + } + } + } +} diff --git a/frontend/src/components/common/StatBoxes/index.tsx b/frontend/src/components/common/StatBoxes/index.tsx index 958ac5c556..11c9ddab62 100644 --- a/frontend/src/components/common/StatBoxes/index.tsx +++ b/frontend/src/components/common/StatBoxes/index.tsx @@ -1,8 +1,8 @@ import classes from "./StatBoxes.module.scss"; -import {IconCash, IconEye, IconReceipt, IconTicket} from "@tabler/icons-react"; +import {IconCash, IconCreditCardRefund, IconEye, IconReceipt, IconShoppingCart, IconUsers} from "@tabler/icons-react"; import {Card} from "../Card"; import {useGetEventStats} from "../../../queries/useGetEventStats.ts"; -import {useParams} from "react-router-dom"; +import {useParams} from "react-router"; import {t} from "@lingui/macro"; import {useGetEvent} from "../../../queries/useGetEvent.ts"; import {formatCurrency} from "../../../utilites/currency.ts"; @@ -17,24 +17,40 @@ export const StatBoxes = () => { const data = [ { - number: formatNumber(eventStats?.total_tickets_sold as number), - description: t`Tickets sold`, - icon: + number: formatNumber(eventStats?.total_products_sold as number), + description: t`Products sold`, + icon: , + backgroundColor: '#4B7BE5' // Deep blue + }, + { + number: formatNumber(eventStats?.total_attendees_registered as number), + description: t`Attendees`, + icon: , + backgroundColor: '#E6677E' // Rose pink + }, + { + number: formatCurrency(eventStats?.total_refunded as number || 0, event?.currency), + description: t`Refunded`, + icon: , + backgroundColor: '#49A6B7' // Teal }, { number: formatCurrency(eventStats?.total_gross_sales || 0, event?.currency), description: t`Gross sales`, - icon: + icon: , + backgroundColor: '#7C63E6' // Purple }, { number: formatNumber(eventStats?.total_views as number), description: t`Page views`, - icon: + icon: , + backgroundColor: '#63B3A1' // Sage green }, { number: formatNumber(eventStats?.total_orders as number), - description: t`Orders Created`, - icon: + description: t`Completed orders`, + icon: , + backgroundColor: '#E67D49' // Coral orange } ]; @@ -46,7 +62,7 @@ export const StatBoxes = () => {
{stat.description}
-
+
{stat.icon}
@@ -60,4 +76,3 @@ export const StatBoxes = () => {
); }; - diff --git a/frontend/src/components/common/Table/index.tsx b/frontend/src/components/common/Table/index.tsx index 9edd123ea2..f528bd33df 100644 --- a/frontend/src/components/common/Table/index.tsx +++ b/frontend/src/components/common/Table/index.tsx @@ -29,4 +29,4 @@ export const Table =({children}: TableProps) => { ); -} \ No newline at end of file +} diff --git a/frontend/src/components/common/TaxAndFeeList/TaxAndFeeList.module.scss b/frontend/src/components/common/TaxAndFeeList/TaxAndFeeList.module.scss index c5069fd5c6..5c3d47e344 100644 --- a/frontend/src/components/common/TaxAndFeeList/TaxAndFeeList.module.scss +++ b/frontend/src/components/common/TaxAndFeeList/TaxAndFeeList.module.scss @@ -38,7 +38,7 @@ border-radius: var(--tk-radius-xs) var(--tk-radius-xs) 0 0; font-size: .8em; display: flex; - padding: 4px 0 4px 10px; + padding: 8px 0 8px 13px; .type { flex: 1; diff --git a/frontend/src/components/common/TicketsTable/SortableTicket/index.tsx b/frontend/src/components/common/TicketsTable/SortableTicket/index.tsx deleted file mode 100644 index 0807b7bf1f..0000000000 --- a/frontend/src/components/common/TicketsTable/SortableTicket/index.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import {IdParam, MessageType, Ticket, TicketPrice, TicketType} from "../../../../types.ts"; -import {useSortable} from "@dnd-kit/sortable"; -import {useDisclosure} from "@mantine/hooks"; -import {useState} from "react"; -import {useDeleteTicket} from "../../../../mutations/useDeleteTicket.ts"; -import {CSS} from "@dnd-kit/utilities"; -import {showError, showSuccess} from "../../../../utilites/notifications.tsx"; -import {t} from "@lingui/macro"; -import {relativeDate} from "../../../../utilites/dates.ts"; -import {formatCurrency} from "../../../../utilites/currency.ts"; -import {Card} from "../../Card"; -import classes from "../TicketsTable.module.scss"; -import classNames from "classnames"; -import {IconDotsVertical, IconEyeOff, IconGripVertical, IconPencil, IconSend, IconTrash} from "@tabler/icons-react"; -import Truncate from "../../Truncate"; -import {Badge, Button, Group, Menu, Popover} from "@mantine/core"; -import {EditTicketModal} from "../../../modals/EditTicketModal"; -import {SendMessageModal} from "../../../modals/SendMessageModal"; -import {UniqueIdentifier} from "@dnd-kit/core"; - -export const SortableTicket = ({ticket, enableSorting, currencyCode}: {ticket: Ticket, enableSorting: boolean, currencyCode: string }) => { - const uniqueId = ticket.id as UniqueIdentifier; - const { - attributes, - listeners, - setNodeRef, - transform, - transition - } = useSortable( - { - id: uniqueId, - } - ); - const [isEditModalOpen, editModal] = useDisclosure(false); - const [isMessageModalOpen, messageModal] = useDisclosure(false); - const [ticketId, setTicketId] = useState(); - const deleteMutation = useDeleteTicket(); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - }; - - const handleModalClick = (ticketId: IdParam, modal: { open: () => void }) => { - setTicketId(ticketId); - modal.open(); - } - - const handleDeleteTicket = (ticketId: IdParam, eventId: IdParam) => { - deleteMutation.mutate({ticketId, eventId}, { - onSuccess: () => { - showSuccess(t`Ticket deleted successfully`) - }, - onError: (error: any) => { - if (error.response?.status === 409) { - showError(error.response.data.message || t`This ticket cannot be deleted because it is - associated with an order. You can hide it instead.`); - } - } - }); - } - - const getTicketStatus = (ticket: Ticket) => { - if (ticket.is_sold_out) { - return t`Sold Out`; - } - - if (ticket.is_before_sale_start_date) { - return t`On sale` + ' ' + relativeDate(ticket.sale_start_date as string); - } - - if (ticket.is_after_sale_end_date) { - return t`Sale ended ` + ' ' + relativeDate(ticket.sale_end_date as string); - } - - if (ticket.is_hidden) { - return t`Hidden from public view`; - } - - return ticket.is_available ? t`On Sale` : t`Not On Sale`; - } - - const getPriceRange = (ticket: Ticket) => { - const ticketPrices: TicketPrice[] = ticket.prices as TicketPrice[]; - - if (ticket.type !== TicketType.Tiered) { - if (ticketPrices[0].price <= 0) { - return t`Free`; - } - return formatCurrency(ticketPrices[0].price, currencyCode); - } - - if (ticketPrices.length === 0) { - return formatCurrency(ticketPrices[0].price, currencyCode) - } - - const prices = ticketPrices.map(ticketPrice => ticketPrice.price); - const minPrice = Math.min(...prices); - const maxPrice = Math.max(...prices); - - if (minPrice <= 0 && maxPrice <= 0) { - return t`Free`; - } - - return formatCurrency(minPrice, currencyCode) + ' - ' + formatCurrency(maxPrice, currencyCode); - } - - return ( - <> -
- -
- -
-
-
-
-
{t`Title`}
- {(ticket.is_hidden_without_promo_code || ticket.is_hidden) && ( - - - - - - {ticket.is_hidden - ? t`This ticket is hidden from public view` - : t`This ticket is hidden unless targeted by a Promo Code`} - - - )} -
-
-
{t`Status`}
- - - - {ticket.is_available ? t`On Sale` : t`Not On Sale`} - - - - {getTicketStatus(ticket)} - - - -
-
-
{t`Price`}
-
- {getPriceRange(ticket)} -
-
-
-
{t`Attendees`}
- {Number(ticket.quantity_sold)} -
-
-
-
- - - -
-
- -
-
- -
-
-
- - - {t`Actions`} - handleModalClick(ticket.id, messageModal)} - leftSection={}>{t`Message Attendees`} - handleModalClick(ticket.id, editModal)} - leftSection={}>{t`Edit Ticket`} - - {t`Danger zone`} - handleDeleteTicket(ticket.id, ticket.event_id)} - color="red" - leftSection={} - > - {t`Delete ticket`} - - -
-
-
-
-
- -
- - {isEditModalOpen && } - {isMessageModalOpen && } - - ); -}; diff --git a/frontend/src/components/common/TicketsTable/TicketsTable.module.scss b/frontend/src/components/common/TicketsTable/TicketsTable.module.scss deleted file mode 100644 index 39e9dc0bbe..0000000000 --- a/frontend/src/components/common/TicketsTable/TicketsTable.module.scss +++ /dev/null @@ -1,152 +0,0 @@ -@import "../../../styles/mixins.scss"; - -.cards { - display: flex; - flex-direction: column; - - .ticketCard { - display: grid; - padding: 20px; - margin-bottom: 20px; - //border-top: 3px solid var(--tk-color-money-green) !important; - position: relative; - gap: 10px; - - grid-template-areas: "dragHanlde ticketInfo action"; - grid-template-columns: 40px 1fr 40px; - - @include respond-below(lg) { - grid-template-areas: "dragHanlde ticketInfo" - "dragHanlde action"; - } - - .halfCircle { - width: 20px; - height: 10px; - background-color: #fbfafb; - border-top-left-radius: 110px; - border-top-right-radius: 110px; - border: 1px solid #ddd; - border-bottom: 0; - transform: rotate(90deg); - position: absolute; - left: -6px; - top: 44%; - } - - .halfCircle.right { - left: auto; - right: -6px; - transform: rotate(270deg); - } - - .dragHandle { - display: flex; - justify-content: center; - align-items: center; - cursor: move; - grid-area: dragHanlde; - touch-action: none; - } - - .dragHandleDisabled { - cursor: not-allowed; - opacity: 0.5; - } - - .ticketInfo { - grid-area: ticketInfo; - - .ticketDetails { - display: grid; - width: 100%; - align-items: center; - gap: 15px; - flex-wrap: wrap; - - grid-template-columns: 1fr 1fr 1fr 1fr; - - @include respond-below(lg) { - flex-direction: column; - align-items: flex-start; - grid-template-columns: 1fr 1fr; - gap: 20px; - } - - @include respond-below(sm) { - gap: 10px; - } - - @include respond-below(xs) { - gap: 20px; - grid-template-columns: 1fr; - } - - > div { - flex: 1; - min-width: 125px; - - @include respond-below(sm) { - min-width: 100px; - } - } - - .heading { - text-transform: uppercase; - color: #9ca3af; - font-size: .8em; - } - - .status { - max-width: 120px; - cursor: pointer; - } - - .title { - text-wrap: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .price { - color: var(--tk-color-money-green); - - .priceAmount { - font-weight: 600; - text-wrap: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } - - .availability { - } - } - } - - .action { - display: flex; - grid-area: action; - - @include respond-below(lg) { - margin-top: 10px; - } - - .desktopAction { - @include respond-below(lg) { - display: none; - } - } - - .mobileAction { - display: none; - @include respond-below(lg) { - display: block; - } - } - } - } -} - - - diff --git a/frontend/src/components/common/TicketsTable/index.tsx b/frontend/src/components/common/TicketsTable/index.tsx deleted file mode 100644 index 15eb8f4e0e..0000000000 --- a/frontend/src/components/common/TicketsTable/index.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import {useEffect} from 'react'; -import classes from './TicketsTable.module.scss'; -import {NoResultsSplash} from "../NoResultsSplash"; -import {t} from "@lingui/macro"; -import { - closestCenter, - DndContext, - PointerSensor, - TouchSensor, - UniqueIdentifier, - useSensor, - useSensors, -} from '@dnd-kit/core'; -import {SortableContext, verticalListSortingStrategy,} from '@dnd-kit/sortable'; -import {Ticket, Event} from "../../../types"; -import {useSortTickets} from "../../../mutations/useSortTickets.ts"; -import {useParams} from "react-router-dom"; -import {showError, showSuccess} from "../../../utilites/notifications.tsx"; -import {SortableTicket} from "./SortableTicket"; -import {useDragItemsHandler} from "../../../hooks/useDragItemsHandler.ts"; -import {Button} from "@mantine/core"; -import {IconPlus} from "@tabler/icons-react"; - -interface TicketCardProps { - tickets: Ticket[]; - event: Event; - enableSorting: boolean; - openCreateModal: () => void; -} - -export const TicketsTable = ({tickets, event, openCreateModal, enableSorting = false}: TicketCardProps) => { - const {eventId} = useParams(); - const sortTicketsMutation = useSortTickets(); - const {items, setItems, handleDragEnd} = useDragItemsHandler({ - initialItemIds: tickets.map((ticket) => Number(ticket.id)), - onSortEnd: (newArray) => { - sortTicketsMutation.mutate({ - sortedTickets: newArray.map((id, index) => { - return {id, order: index + 1}; - }), - eventId: eventId, - }, { - onSuccess: () => { - showSuccess(t`Tickets sorted successfully`); - }, - onError: () => { - showError(t`An error occurred while sorting the tickets. Please try again or refresh the page`); - } - }) - }, - }); - - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(TouchSensor) - ); - - useEffect(() => { - setItems(tickets.map((ticket) => Number(ticket.id))); - }, [tickets]); - - if (tickets.length === 0) { - return -

- {t`You'll need at least one ticket to get started. Free, paid or let the user decide what to pay.`} -

- - - )} - />; - } - - const handleDragStart = (event: any) => { - if (!enableSorting) { - showError(t`Please remove filters and set sorting to "Homepage order" to enable sorting`); - event.cancel(); - } - } - - return ( - - -
- {items.map((ticketId) => { - const ticket = tickets.find((t) => t.id === ticketId); - - if (!ticket) { - return null; - } - - return ( - - ); - })} -
-
-
- ); -}; diff --git a/frontend/src/components/common/ToolBar/ToolBar.module.scss b/frontend/src/components/common/ToolBar/ToolBar.module.scss index 44a9add6aa..a6b2081355 100644 --- a/frontend/src/components/common/ToolBar/ToolBar.module.scss +++ b/frontend/src/components/common/ToolBar/ToolBar.module.scss @@ -2,6 +2,7 @@ .card { container-type: inline-size; + margin-bottom: 1rem; .wrapper { display: flex; @@ -12,11 +13,31 @@ @include 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 respond-below(sm, true) { + width: 100%; + justify-content: flex-end; + } + } + + .filter { + display: flex; + align-items: center; } .actions { @@ -24,10 +45,11 @@ gap: 10px; align-items: center; place-self: flex-end; + flex-wrap: wrap; } } button { height: 42px !important; } -} \ No newline at end of file +} diff --git a/frontend/src/components/common/ToolBar/index.tsx b/frontend/src/components/common/ToolBar/index.tsx index 60ab4465be..5f8f7dd3e1 100644 --- a/frontend/src/components/common/ToolBar/index.tsx +++ b/frontend/src/components/common/ToolBar/index.tsx @@ -1,23 +1,43 @@ import React from "react"; import {Card} from "../Card"; -import classes from './ToolBar.module.scss' +import classes from './ToolBar.module.scss'; +import {Group} from '@mantine/core'; interface ToolBarProps { - children: React.ReactNode[] | React.ReactNode, - searchComponent?: () => React.ReactNode, + children?: React.ReactNode[] | React.ReactNode; + searchComponent?: () => React.ReactNode; + filterComponent?: React.ReactNode; + className?: string; } -export const ToolBar = ({searchComponent, children}: ToolBarProps) => { +export const ToolBar: React.FC = ({ + searchComponent, + filterComponent, + children, + className, + }) => { return ( - +
-
- {searchComponent && searchComponent()} -
-
- {children} -
+ {searchComponent && ( +
+ {searchComponent()} +
+ )} + + + {filterComponent && ( +
+ {filterComponent} +
+ )} + {children && ( +
+ {children} +
+ )} +
- ) -} \ No newline at end of file + ); +}; diff --git a/frontend/src/components/common/WebhookTable/WebhookTable.module.scss b/frontend/src/components/common/WebhookTable/WebhookTable.module.scss new file mode 100644 index 0000000000..8d74445acd --- /dev/null +++ b/frontend/src/components/common/WebhookTable/WebhookTable.module.scss @@ -0,0 +1,26 @@ + +.responseBody { + max-height: 300px; + overflow-y: auto; + background-color: var(--mantine-color-gray-0); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: var(--mantine-font-size-xs); + + pre { + margin: 0; + white-space: pre-wrap; + word-break: break-all; + } +} + +.tooltipContent { + padding: 0.5rem; + + > div { + margin-bottom: 0.25rem; + + &:last-child { + margin-bottom: 0; + } + } +} diff --git a/frontend/src/components/common/WebhookTable/index.tsx b/frontend/src/components/common/WebhookTable/index.tsx new file mode 100644 index 0000000000..6a87c53007 --- /dev/null +++ b/frontend/src/components/common/WebhookTable/index.tsx @@ -0,0 +1,291 @@ +import { + Anchor, + Badge, + Button, + Group, + Menu, + Paper, + Popover, + Stack, + Table as MantineTable, + Text, + Tooltip +} from '@mantine/core'; +import { + IconBolt, + IconClipboardList, + IconClockHour4, + IconDotsVertical, + IconPencil, + IconPlus, + IconTrash +} from '@tabler/icons-react'; +import {Table, TableHead} from '../Table'; +import classes from './WebhookTable.module.scss'; +import {IdParam, Webhook} from '../../../types'; +import {confirmationDialog} from '../../../utilites/confirmationDialog'; +import Truncate from '../Truncate'; +import {relativeDate} from "../../../utilites/dates.ts"; +import {useDisclosure} from "@mantine/hooks"; +import {useState} from "react"; +import {t, Trans} from "@lingui/macro"; +import {EditWebhookModal} from "../../modals/EditWebhookModal"; +import {useDeleteWebhook} from "../../../mutations/useDeleteWebhook.ts"; +import {useParams} from "react-router"; +import {showError, showSuccess} from "../../../utilites/notifications.tsx"; +import {NoResultsSplash} from "../NoResultsSplash"; +import {WebhookLogsModal} from "../../modals/WebhookLogsModal"; + +interface WebhookTableProps { + webhooks: Webhook[]; + openCreateModal: () => void; +} + +export const WebhookTable = ({webhooks, openCreateModal}: WebhookTableProps) => { + const {eventId} = useParams(); + const [editModalOpen, {open: openEditModal, close: closeEditModal}] = useDisclosure(false); + const [logsModalOpen, {open: openLogsModal, close: closeLogsModal}] = useDisclosure(false); + const [selectedWebhookId, setSelectedWebhookId] = useState(); + const deleteMutation = useDeleteWebhook(); + + const handleDelete = (webhookId: IdParam) => { + deleteMutation.mutate({eventId, webhookId}, { + onSuccess: () => showSuccess(t`Webhook deleted successfully`), + onError: (error) => showError(error.message) + }); + } + + const EventTypeDisplay = ({webhook}: { webhook: Webhook }) => { + const eventTypes = webhook.event_types; + + if (!eventTypes || eventTypes.length === 0) { + return <>-; + } + + const eventCount = eventTypes.length; + + return ( +
+ + {eventTypes.map((type) => ( +
{type}
+ ))} +
+ } + > + + {eventCount > 1 ? {eventCount} events : eventTypes[0]} + + +
+ ); + }; + + const ActionMenu = ({webhook}: { webhook: Webhook }) => ( + + + + + + + + Manage + } + onClick={() => { + setSelectedWebhookId(webhook.id as IdParam); + openEditModal(); + }} + > + {t`Edit webhook`} + + } + onClick={() => { + setSelectedWebhookId(webhook.id as IdParam); + openLogsModal(); + }} + > + {t`View logs`} + + + {t`Danger zone`} + } + onClick={() => { + confirmationDialog( + t`Are you sure you want to delete this webhook?`, + () => handleDelete(webhook.id as IdParam) + ); + }} + > + {t`Delete webhook`} + + + + + ); + + + const ResponseDisplay = ({webhook}: { webhook: Webhook }) => { + if (webhook.last_response_code === null || webhook.last_response_code === undefined) { + return ( + + + + {t`No responses yet`} + + + ); + } + + const isSuccess = (webhook.last_response_code >= 200 && webhook.last_response_code < 300) && webhook.last_response_code !== 0; + const statusColor = isSuccess ? 'green' : 'red'; + const statusText = isSuccess ? 'Success' : 'Error'; + + return ( + + + + } + > + {statusText} {webhook.last_response_code > 0 ? `- ${webhook.last_response_code}` : ''} + + + + + + + + Response Details + + {webhook.last_response_code > 0 ? webhook.last_response_code : t`No response`} + + + + {webhook.last_response_body && ( + + + {webhook.last_response_body} + + + )} + + + + ); + }; + + if (webhooks.length === 0) { + return ( + + +

+ Webhooks instantly notify external services when events happen, like adding a new attendee + to your CRM or mailing list upon registration, ensuring seamless automation. +

+

+ Use third-party services like Zapier,{' '} + IFTTT or Make to + create custom workflows and automate tasks. +

+
+ + + )} + /> + ); + } + + return ( + <> + + + + {t`URL`} + {t`Event Types`} + {t`Status`} + {t`Last Response`} + {t`Last Triggered`} + + + + + {webhooks.map((webhook) => ( + + + + + + + + + + {webhook.status} + + + + + + + + {webhook.last_triggered_at ? relativeDate(webhook.last_triggered_at as string) : t`Never`} + + + + + + + ))} + +
+ {logsModalOpen && selectedWebhookId && ( + + )} + + {(editModalOpen && selectedWebhookId) && ( + + )} + + ); +}; diff --git a/frontend/src/components/common/WidgetEditor/index.tsx b/frontend/src/components/common/WidgetEditor/index.tsx index 93a812618f..1cb3ab4a5c 100644 --- a/frontend/src/components/common/WidgetEditor/index.tsx +++ b/frontend/src/components/common/WidgetEditor/index.tsx @@ -1,11 +1,11 @@ import classes from './WidgetEditor.module.scss'; -import SelectTickets from "../../routes/ticket-widget/SelectTickets"; +import SelectProducts from "../../routes/product-widget/SelectProducts"; import {ColorInput, Group, NumberInput, Switch, Tabs, Textarea, TextInput} from "@mantine/core"; import {t, Trans} from "@lingui/macro"; import {matches, useForm} from "@mantine/form"; import {useEffect, useState} from "react"; import {CopyButton} from "../CopyButton"; -import {useParams} from "react-router-dom"; +import {useParams} from "react-router"; import {IconInfoCircle} from "@tabler/icons-react"; import {useGetEventSettings} from "../../../queries/useGetEventSettings.ts"; import {Popover} from "../Popover"; @@ -253,6 +253,7 @@ export default App;