diff --git a/.github/workflows/post-release-push-images.yml b/.github/workflows/post-release-push-images.yml index d6000b3210..145ebfdf22 100644 --- a/.github/workflows/post-release-push-images.yml +++ b/.github/workflows/post-release-push-images.yml @@ -9,6 +9,7 @@ jobs: push_to_registry: name: Push Docker images to Docker Hub runs-on: ubuntu-latest + steps: - name: Check out the repo uses: actions/checkout@v4 @@ -28,6 +29,9 @@ jobs: uses: docker/metadata-action@v3 with: images: daveearley/hi.events-all-in-one + tags: | + type=ref,event=tag + type=raw,value=latest,enable=${{ github.event.release.prerelease == false }} - name: Build and push All-in-one Docker image uses: docker/build-push-action@v3 @@ -44,6 +48,9 @@ jobs: uses: docker/metadata-action@v3 with: images: daveearley/hi.events-backend + tags: | + type=ref,event=tag + type=raw,value=latest,enable=${{ github.event.release.prerelease == false }} - name: Build and push Backend Docker image uses: docker/build-push-action@v3 @@ -60,6 +67,9 @@ jobs: uses: docker/metadata-action@v3 with: images: daveearley/hi.events-frontend + tags: | + type=ref,event=tag + type=raw,value=latest,enable=${{ github.event.release.prerelease == false }} - name: Build and push Frontend Docker image uses: docker/build-push-action@v3 diff --git a/backend/app/DomainObjects/AccountDomainObject.php b/backend/app/DomainObjects/AccountDomainObject.php index 33cdb98285..92b24cad9f 100644 --- a/backend/app/DomainObjects/AccountDomainObject.php +++ b/backend/app/DomainObjects/AccountDomainObject.php @@ -3,11 +3,16 @@ namespace HiEvents\DomainObjects; use HiEvents\DomainObjects\DTO\AccountApplicationFeeDTO; +use HiEvents\DomainObjects\Enums\StripePlatform; +use Illuminate\Support\Collection; class AccountDomainObject extends Generated\AccountDomainObjectAbstract { private ?AccountConfigurationDomainObject $configuration = null; + /** @var Collection|null */ + private ?Collection $stripePlatforms = null; + public function getApplicationFee(): AccountApplicationFeeDTO { /** @var AccountConfigurationDomainObject $applicationFee */ @@ -28,4 +33,68 @@ public function setConfiguration(AccountConfigurationDomainObject $configuration { $this->configuration = $configuration; } + + public function getAccountStripePlatforms(): ?Collection + { + return $this->stripePlatforms; + } + + public function setAccountStripePlatforms(Collection $stripePlatforms): void + { + $this->stripePlatforms = $stripePlatforms; + } + + /** + * Get the primary active Stripe platform for this account + * Returns the platform with setup completed, preferring the most recent + */ + public function getPrimaryStripePlatform(): ?AccountStripePlatformDomainObject + { + if (!$this->stripePlatforms || $this->stripePlatforms->isEmpty()) { + return null; + } + + return $this->stripePlatforms + ->filter(fn($platform) => $platform->getStripeSetupCompletedAt() !== null) + ->sortByDesc(fn($platform) => $platform->getCreatedAt()) + ->first(); + } + + /** + * Get the Stripe platform for a specific platform type + * Handles null platform for open-source installations + */ + public function getStripePlatformByType(?StripePlatform $platformType): ?AccountStripePlatformDomainObject + { + if (!$this->stripePlatforms || $this->stripePlatforms->isEmpty()) { + return null; + } + + return $this->stripePlatforms + ->filter(fn($platform) => $platform->getStripeConnectPlatform() === $platformType?->value) + ->first(); + } + + public function getActiveStripeAccountId(): ?string + { + return $this->getPrimaryStripePlatform()?->getStripeAccountId(); + } + + public function getActiveStripePlatform(): ?StripePlatform + { + $primaryPlatform = $this->getPrimaryStripePlatform(); + if (!$primaryPlatform || !$primaryPlatform->getStripeConnectPlatform()) { + return null; + } + + return StripePlatform::fromString($primaryPlatform->getStripeConnectPlatform()); + } + + /** + * Check if Stripe is set up and ready for payments + */ + public function isStripeSetupComplete(): bool + { + return $this->getPrimaryStripePlatform() !== null; + } } diff --git a/backend/app/DomainObjects/AccountStripePlatformDomainObject.php b/backend/app/DomainObjects/AccountStripePlatformDomainObject.php new file mode 100644 index 0000000000..20b0a0ecf8 --- /dev/null +++ b/backend/app/DomainObjects/AccountStripePlatformDomainObject.php @@ -0,0 +1,7 @@ + __('Liquid'), + self::BLADE => __('Blade'), + }; + } +} \ No newline at end of file diff --git a/backend/app/DomainObjects/Enums/EmailTemplateType.php b/backend/app/DomainObjects/Enums/EmailTemplateType.php new file mode 100644 index 0000000000..8b11aac906 --- /dev/null +++ b/backend/app/DomainObjects/Enums/EmailTemplateType.php @@ -0,0 +1,27 @@ + __('Order Confirmation'), + self::ATTENDEE_TICKET => __('Attendee Ticket'), + }; + } + + public function description(): string + { + return match ($this) { + self::ORDER_CONFIRMATION => __('Sent to the customer after placing an order'), + self::ATTENDEE_TICKET => __('Sent to each attendee with their ticket'), + }; + } +} \ No newline at end of file diff --git a/backend/app/DomainObjects/Enums/ImageType.php b/backend/app/DomainObjects/Enums/ImageType.php index 6f50bd5857..df38a04721 100644 --- a/backend/app/DomainObjects/Enums/ImageType.php +++ b/backend/app/DomainObjects/Enums/ImageType.php @@ -15,6 +15,7 @@ enum ImageType // Event images case EVENT_COVER; + case TICKET_LOGO; // Organizer images case ORGANIZER_LOGO; @@ -24,6 +25,7 @@ public static function eventImageTypes(): array { return [ self::EVENT_COVER, + self::TICKET_LOGO, ]; } @@ -47,6 +49,7 @@ public static function getMinimumDimensionsMap(ImageType $imageType): array $map = [ self::GENERIC->name => [50, 50], self::EVENT_COVER->name => [600, 50], + self::TICKET_LOGO->name => [100, 100], self::ORGANIZER_LOGO->name => [100, 100], self::ORGANIZER_COVER->name => [600, 50], ]; diff --git a/backend/app/DomainObjects/Enums/StripePlatform.php b/backend/app/DomainObjects/Enums/StripePlatform.php new file mode 100644 index 0000000000..306501c6fa --- /dev/null +++ b/backend/app/DomainObjects/Enums/StripePlatform.php @@ -0,0 +1,28 @@ +value; + } + + public static function getAllValues(): array + { + return array_column(self::cases(), 'value'); + } +} \ No newline at end of file diff --git a/backend/app/DomainObjects/Generated/AccountDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/AccountDomainObjectAbstract.php index 0c3a27fbf0..638572e011 100644 --- a/backend/app/DomainObjects/Generated/AccountDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/AccountDomainObjectAbstract.php @@ -19,11 +19,8 @@ abstract class AccountDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr final public const DELETED_AT = 'deleted_at'; final public const NAME = 'name'; final public const EMAIL = 'email'; - final public const STRIPE_ACCOUNT_ID = 'stripe_account_id'; 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; @@ -35,11 +32,8 @@ abstract class AccountDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr protected ?string $deleted_at = null; protected string $name; protected string $email; - protected ?string $stripe_account_id = null; 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 @@ -54,11 +48,8 @@ public function toArray(): array 'deleted_at' => $this->deleted_at ?? null, 'name' => $this->name ?? null, 'email' => $this->email ?? null, - 'stripe_account_id' => $this->stripe_account_id ?? null, '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, ]; } @@ -162,17 +153,6 @@ public function getEmail(): string return $this->email; } - public function setStripeAccountId(?string $stripe_account_id): self - { - $this->stripe_account_id = $stripe_account_id; - return $this; - } - - public function getStripeAccountId(): ?string - { - return $this->stripe_account_id; - } - public function setShortId(string $short_id): self { $this->short_id = $short_id; @@ -184,17 +164,6 @@ public function getShortId(): string return $this->short_id; } - public function setStripeConnectSetupComplete(?bool $stripe_connect_setup_complete): self - { - $this->stripe_connect_setup_complete = $stripe_connect_setup_complete; - return $this; - } - - public function getStripeConnectSetupComplete(): ?bool - { - return $this->stripe_connect_setup_complete; - } - public function setAccountVerifiedAt(?string $account_verified_at): self { $this->account_verified_at = $account_verified_at; @@ -206,17 +175,6 @@ 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; diff --git a/backend/app/DomainObjects/Generated/AccountStripePlatformDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/AccountStripePlatformDomainObjectAbstract.php new file mode 100644 index 0000000000..a19e26bfa0 --- /dev/null +++ b/backend/app/DomainObjects/Generated/AccountStripePlatformDomainObjectAbstract.php @@ -0,0 +1,160 @@ + $this->id ?? null, + 'account_id' => $this->account_id ?? null, + 'stripe_connect_account_type' => $this->stripe_connect_account_type ?? null, + 'stripe_connect_platform' => $this->stripe_connect_platform ?? null, + 'stripe_account_id' => $this->stripe_account_id ?? null, + 'stripe_setup_completed_at' => $this->stripe_setup_completed_at ?? null, + 'stripe_account_details' => $this->stripe_account_details ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + 'deleted_at' => $this->deleted_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setAccountId(int $account_id): self + { + $this->account_id = $account_id; + return $this; + } + + public function getAccountId(): int + { + return $this->account_id; + } + + public function setStripeConnectAccountType(?string $stripe_connect_account_type): self + { + $this->stripe_connect_account_type = $stripe_connect_account_type; + return $this; + } + + public function getStripeConnectAccountType(): ?string + { + return $this->stripe_connect_account_type; + } + + public function setStripeConnectPlatform(?string $stripe_connect_platform): self + { + $this->stripe_connect_platform = $stripe_connect_platform; + return $this; + } + + public function getStripeConnectPlatform(): ?string + { + return $this->stripe_connect_platform; + } + + public function setStripeAccountId(?string $stripe_account_id): self + { + $this->stripe_account_id = $stripe_account_id; + return $this; + } + + public function getStripeAccountId(): ?string + { + return $this->stripe_account_id; + } + + public function setStripeSetupCompletedAt(?string $stripe_setup_completed_at): self + { + $this->stripe_setup_completed_at = $stripe_setup_completed_at; + return $this; + } + + public function getStripeSetupCompletedAt(): ?string + { + return $this->stripe_setup_completed_at; + } + + public function setStripeAccountDetails(array|string|null $stripe_account_details): self + { + $this->stripe_account_details = $stripe_account_details; + return $this; + } + + public function getStripeAccountDetails(): array|string|null + { + return $this->stripe_account_details; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } +} diff --git a/backend/app/DomainObjects/Generated/EmailTemplateDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EmailTemplateDomainObjectAbstract.php new file mode 100644 index 0000000000..bd7818e7d4 --- /dev/null +++ b/backend/app/DomainObjects/Generated/EmailTemplateDomainObjectAbstract.php @@ -0,0 +1,202 @@ + $this->id ?? null, + 'account_id' => $this->account_id ?? null, + 'organizer_id' => $this->organizer_id ?? null, + 'event_id' => $this->event_id ?? null, + 'template_type' => $this->template_type ?? null, + 'subject' => $this->subject ?? null, + 'body' => $this->body ?? null, + 'cta' => $this->cta ?? null, + 'engine' => $this->engine ?? null, + 'is_active' => $this->is_active ?? 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 setAccountId(int $account_id): self + { + $this->account_id = $account_id; + return $this; + } + + public function getAccountId(): int + { + return $this->account_id; + } + + public function setOrganizerId(?int $organizer_id): self + { + $this->organizer_id = $organizer_id; + return $this; + } + + public function getOrganizerId(): ?int + { + return $this->organizer_id; + } + + public function setEventId(?int $event_id): self + { + $this->event_id = $event_id; + return $this; + } + + public function getEventId(): ?int + { + return $this->event_id; + } + + public function setTemplateType(string $template_type): self + { + $this->template_type = $template_type; + return $this; + } + + public function getTemplateType(): string + { + return $this->template_type; + } + + public function setSubject(string $subject): self + { + $this->subject = $subject; + return $this; + } + + public function getSubject(): string + { + return $this->subject; + } + + public function setBody(string $body): self + { + $this->body = $body; + return $this; + } + + public function getBody(): string + { + return $this->body; + } + + public function setCta(array|string|null $cta): self + { + $this->cta = $cta; + return $this; + } + + public function getCta(): array|string|null + { + return $this->cta; + } + + public function setEngine(string $engine): self + { + $this->engine = $engine; + return $this; + } + + public function getEngine(): string + { + return $this->engine; + } + + public function setIsActive(bool $is_active): self + { + $this->is_active = $is_active; + return $this; + } + + public function getIsActive(): bool + { + return $this->is_active; + } + + 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/EventDailyStatisticDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventDailyStatisticDomainObjectAbstract.php index ac1749cb9c..ac490a6fe7 100644 --- a/backend/app/DomainObjects/Generated/EventDailyStatisticDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventDailyStatisticDomainObjectAbstract.php @@ -26,6 +26,7 @@ abstract class EventDailyStatisticDomainObjectAbstract extends \HiEvents\DomainO final public const TOTAL_REFUNDED = 'total_refunded'; final public const TOTAL_VIEWS = 'total_views'; final public const ATTENDEES_REGISTERED = 'attendees_registered'; + final public const ORDERS_CANCELLED = 'orders_cancelled'; protected int $id; protected int $event_id; @@ -43,6 +44,7 @@ abstract class EventDailyStatisticDomainObjectAbstract extends \HiEvents\DomainO protected float $total_refunded = 0.0; protected int $total_views = 0; protected int $attendees_registered = 0; + protected int $orders_cancelled = 0; public function toArray(): array { @@ -63,6 +65,7 @@ public function toArray(): array 'total_refunded' => $this->total_refunded ?? null, 'total_views' => $this->total_views ?? null, 'attendees_registered' => $this->attendees_registered ?? null, + 'orders_cancelled' => $this->orders_cancelled ?? null, ]; } @@ -241,4 +244,15 @@ public function getAttendeesRegistered(): int { return $this->attendees_registered; } + + public function setOrdersCancelled(int $orders_cancelled): self + { + $this->orders_cancelled = $orders_cancelled; + return $this; + } + + public function getOrdersCancelled(): int + { + return $this->orders_cancelled; + } } diff --git a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php index ed4088fd01..ad91a0ffb6 100644 --- a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php @@ -58,6 +58,7 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ 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'; + final public const TICKET_DESIGN_SETTINGS = 'ticket_design_settings'; protected int $id; protected int $event_id; @@ -107,6 +108,7 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ protected bool $allow_orders_awaiting_offline_payment_to_check_in = false; protected ?int $invoice_payment_terms_days = null; protected ?string $invoice_notes = null; + protected array|string|null $ticket_design_settings = null; public function toArray(): array { @@ -159,6 +161,7 @@ public function toArray(): array '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, + 'ticket_design_settings' => $this->ticket_design_settings ?? null, ]; } @@ -690,4 +693,15 @@ public function getInvoiceNotes(): ?string { return $this->invoice_notes; } + + public function setTicketDesignSettings(array|string|null $ticket_design_settings): self + { + $this->ticket_design_settings = $ticket_design_settings; + return $this; + } + + public function getTicketDesignSettings(): array|string|null + { + return $this->ticket_design_settings; + } } diff --git a/backend/app/DomainObjects/Generated/EventStatisticDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventStatisticDomainObjectAbstract.php index e92c464041..259128d3c2 100644 --- a/backend/app/DomainObjects/Generated/EventStatisticDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventStatisticDomainObjectAbstract.php @@ -26,6 +26,7 @@ abstract class EventStatisticDomainObjectAbstract extends \HiEvents\DomainObject final public const ORDERS_CREATED = 'orders_created'; final public const TOTAL_REFUNDED = 'total_refunded'; final public const ATTENDEES_REGISTERED = 'attendees_registered'; + final public const ORDERS_CANCELLED = 'orders_cancelled'; protected int $id; protected int $event_id; @@ -43,6 +44,7 @@ abstract class EventStatisticDomainObjectAbstract extends \HiEvents\DomainObject protected int $orders_created = 0; protected float $total_refunded = 0.0; protected int $attendees_registered = 0; + protected int $orders_cancelled = 0; public function toArray(): array { @@ -63,6 +65,7 @@ public function toArray(): array 'orders_created' => $this->orders_created ?? null, 'total_refunded' => $this->total_refunded ?? null, 'attendees_registered' => $this->attendees_registered ?? null, + 'orders_cancelled' => $this->orders_cancelled ?? null, ]; } @@ -241,4 +244,15 @@ public function getAttendeesRegistered(): int { return $this->attendees_registered; } + + public function setOrdersCancelled(int $orders_cancelled): self + { + $this->orders_cancelled = $orders_cancelled; + return $this; + } + + public function getOrdersCancelled(): int + { + return $this->orders_cancelled; + } } diff --git a/backend/app/DomainObjects/Generated/OrderDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrderDomainObjectAbstract.php index a2cbd9cad6..66d7acdad1 100644 --- a/backend/app/DomainObjects/Generated/OrderDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/OrderDomainObjectAbstract.php @@ -42,6 +42,7 @@ abstract class OrderDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac final public const LOCALE = 'locale'; final public const PAYMENT_PROVIDER = 'payment_provider'; final public const NOTES = 'notes'; + final public const STATISTICS_DECREMENTED_AT = 'statistics_decremented_at'; protected int $id; protected int $event_id; @@ -75,6 +76,7 @@ abstract class OrderDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac protected string $locale = 'en'; protected ?string $payment_provider = null; protected ?string $notes = null; + protected ?string $statistics_decremented_at = null; public function toArray(): array { @@ -111,6 +113,7 @@ public function toArray(): array 'locale' => $this->locale ?? null, 'payment_provider' => $this->payment_provider ?? null, 'notes' => $this->notes ?? null, + 'statistics_decremented_at' => $this->statistics_decremented_at ?? null, ]; } @@ -465,4 +468,15 @@ public function getNotes(): ?string { return $this->notes; } + + public function setStatisticsDecrementedAt(?string $statistics_decremented_at): self + { + $this->statistics_decremented_at = $statistics_decremented_at; + return $this; + } + + public function getStatisticsDecrementedAt(): ?string + { + return $this->statistics_decremented_at; + } } diff --git a/backend/app/DomainObjects/Generated/StripePaymentDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/StripePaymentDomainObjectAbstract.php index a09510601b..29c02c9241 100644 --- a/backend/app/DomainObjects/Generated/StripePaymentDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/StripePaymentDomainObjectAbstract.php @@ -22,6 +22,7 @@ abstract class StripePaymentDomainObjectAbstract extends \HiEvents\DomainObjects final public const LAST_ERROR = 'last_error'; final public const CONNECTED_ACCOUNT_ID = 'connected_account_id'; final public const APPLICATION_FEE = 'application_fee'; + final public const STRIPE_PLATFORM = 'stripe_platform'; protected int $id; protected int $order_id; @@ -35,6 +36,7 @@ abstract class StripePaymentDomainObjectAbstract extends \HiEvents\DomainObjects protected array|string|null $last_error = null; protected ?string $connected_account_id = null; protected int $application_fee = 0; + protected ?string $stripe_platform = null; public function toArray(): array { @@ -51,6 +53,7 @@ public function toArray(): array 'last_error' => $this->last_error ?? null, 'connected_account_id' => $this->connected_account_id ?? null, 'application_fee' => $this->application_fee ?? null, + 'stripe_platform' => $this->stripe_platform ?? null, ]; } @@ -185,4 +188,15 @@ public function getApplicationFee(): int { return $this->application_fee; } + + public function setStripePlatform(?string $stripe_platform): self + { + $this->stripe_platform = $stripe_platform; + return $this; + } + + public function getStripePlatform(): ?string + { + return $this->stripe_platform; + } } diff --git a/backend/app/DomainObjects/OrderDomainObject.php b/backend/app/DomainObjects/OrderDomainObject.php index 528e4338f2..723e98b904 100644 --- a/backend/app/DomainObjects/OrderDomainObject.php +++ b/backend/app/DomainObjects/OrderDomainObject.php @@ -2,11 +2,13 @@ namespace HiEvents\DomainObjects; +use HiEvents\DomainObjects\Enums\PaymentProviders; use HiEvents\DomainObjects\Enums\ProductType; use HiEvents\DomainObjects\Interfaces\IsFilterable; use HiEvents\DomainObjects\Interfaces\IsSortable; use HiEvents\DomainObjects\SortingAndFiltering\AllowedSorts; use HiEvents\DomainObjects\Status\OrderPaymentStatus; +use HiEvents\DomainObjects\Status\OrderRefundStatus; use HiEvents\DomainObjects\Status\OrderStatus; use HiEvents\Helper\AddressHelper; use Illuminate\Support\Carbon; @@ -276,4 +278,12 @@ public function getSessionIdentifier(): ?string { return $this->sessionIdentifier; } + + public function isRefundable(): bool + { + return !$this->isFreeOrder() + && $this->getStatus() !== OrderPaymentStatus::AWAITING_OFFLINE_PAYMENT->name + && $this->getPaymentProvider() === PaymentProviders::STRIPE->name + && $this->getRefundStatus() !== OrderRefundStatus::REFUNDED->name; + } } diff --git a/backend/app/DomainObjects/ProductPriceDomainObject.php b/backend/app/DomainObjects/ProductPriceDomainObject.php index 0919781ec1..97ee5ee23b 100644 --- a/backend/app/DomainObjects/ProductPriceDomainObject.php +++ b/backend/app/DomainObjects/ProductPriceDomainObject.php @@ -113,4 +113,9 @@ public function getOffSaleReason(): ?string { return $this->offSaleReason; } + + public function isFree(): bool + { + return $this->getPrice() === 0.00; + } } diff --git a/backend/app/DomainObjects/Status/OrderStatus.php b/backend/app/DomainObjects/Status/OrderStatus.php index ea6d0fd372..28b553affb 100644 --- a/backend/app/DomainObjects/Status/OrderStatus.php +++ b/backend/app/DomainObjects/Status/OrderStatus.php @@ -2,8 +2,12 @@ namespace HiEvents\DomainObjects\Status; +use HiEvents\DomainObjects\Enums\BaseEnum; + enum OrderStatus { + use BaseEnum; + case RESERVED; case CANCELLED; case COMPLETED; diff --git a/backend/app/DomainObjects/StripePaymentDomainObject.php b/backend/app/DomainObjects/StripePaymentDomainObject.php index 7700882a29..85cb081af1 100644 --- a/backend/app/DomainObjects/StripePaymentDomainObject.php +++ b/backend/app/DomainObjects/StripePaymentDomainObject.php @@ -2,6 +2,8 @@ namespace HiEvents\DomainObjects; +use HiEvents\DomainObjects\Enums\StripePlatform; + class StripePaymentDomainObject extends Generated\StripePaymentDomainObjectAbstract { private ?OrderDomainObject $order = null; @@ -16,4 +18,14 @@ public function setOrder(?OrderDomainObject $order): self $this->order = $order; return $this; } + + /** + * Get the Stripe platform enum for this payment + */ + public function getStripePlatformEnum(): ?StripePlatform + { + return $this->getStripePlatform() + ? StripePlatform::fromString($this->getStripePlatform()) + : null; + } } diff --git a/backend/app/Exceptions/EmailTemplateNotFoundException.php b/backend/app/Exceptions/EmailTemplateNotFoundException.php new file mode 100644 index 0000000000..96b1f642c0 --- /dev/null +++ b/backend/app/Exceptions/EmailTemplateNotFoundException.php @@ -0,0 +1,10 @@ +data = $data; - $this->questions = $questions; + $this->productQuestions = $productQuestions; + $this->orderQuestions = $orderQuestions; return $this; } @@ -43,7 +46,8 @@ public function collection(): AnonymousResourceCollection public function headings(): array { - $questionTitles = $this->questions->map(fn($question) => $question->getTitle())->toArray(); + $productQuestionTitles = $this->productQuestions->map(fn($question) => $question->getTitle())->toArray(); + $orderQuestionsTitles = $this->orderQuestions->map(fn($orderQuestion) => $orderQuestion->getTitle())->toArray(); return array_merge([ __('ID'), @@ -61,7 +65,7 @@ public function headings(): array __('Created Date'), __('Last Updated Date'), __('Notes'), - ], $questionTitles); + ], $productQuestionTitles, $orderQuestionsTitles); } /** @@ -70,7 +74,7 @@ public function headings(): array */ public function map($attendee): array { - $answers = $this->questions->map(function (QuestionDomainObject $question) use ($attendee) { + $productAnswers = $this->productQuestions->map(function (QuestionDomainObject $question) use ($attendee) { $answer = $attendee->getQuestionAndAnswerViews() ->first(fn($qav) => $qav->getQuestionId() === $question->getId())?->getAnswer() ?? ''; @@ -80,6 +84,18 @@ public function map($attendee): array ); }); + $orderAnswers = $this->orderQuestions->map(function (QuestionDomainObject $question) use ($attendee) { + /** @var OrderDomainObject $order */ + $order = $attendee->getOrder(); + $answer = $order->getQuestionAndAnswerViews() + ->first(fn($qav) => $qav->getQuestionId() === $question->getId())?->getAnswer() ?? ''; + + return $this->questionAnswerFormatter->getAnswerAsText( + $answer, + QuestionTypeEnum::fromName($question->getType()), + ); + }); + /** @var ProductDomainObject $ticket */ $ticket = $attendee->getProduct(); $ticketName = $ticket->getTitle(); @@ -108,7 +124,7 @@ public function map($attendee): array 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()); + ], $productAnswers->toArray(), $orderAnswers->toArray()); } public function styles(Worksheet $sheet): array diff --git a/backend/app/Http/Actions/Accounts/GetAccountAction.php b/backend/app/Http/Actions/Accounts/GetAccountAction.php index e24caf412a..2bf9bf6686 100644 --- a/backend/app/Http/Actions/Accounts/GetAccountAction.php +++ b/backend/app/Http/Actions/Accounts/GetAccountAction.php @@ -5,6 +5,7 @@ namespace HiEvents\Http\Actions\Accounts; use HiEvents\DomainObjects\AccountConfigurationDomainObject; +use HiEvents\DomainObjects\AccountStripePlatformDomainObject; use HiEvents\DomainObjects\Enums\Role; use HiEvents\Http\Actions\BaseAction; use HiEvents\Repository\Eloquent\Value\Relationship; @@ -30,6 +31,7 @@ public function __invoke(?int $accountId = null): JsonResponse domainObject: AccountConfigurationDomainObject::class, name: 'configuration', )) + ->loadRelation(AccountStripePlatformDomainObject::class) ->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 4da827190d..224121ebfb 100644 --- a/backend/app/Http/Actions/Accounts/Stripe/CreateStripeConnectAccountAction.php +++ b/backend/app/Http/Actions/Accounts/Stripe/CreateStripeConnectAccountAction.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\AccountDomainObject; use HiEvents\DomainObjects\Enums\Role; +use HiEvents\DomainObjects\Enums\StripePlatform; use HiEvents\Exceptions\CreateStripeConnectAccountFailedException; use HiEvents\Exceptions\CreateStripeConnectAccountLinksFailedException; use HiEvents\Exceptions\SaasModeEnabledException; @@ -12,6 +13,7 @@ use HiEvents\Services\Application\Handlers\Account\Payment\Stripe\CreateStripeConnectAccountHandler; use HiEvents\Services\Application\Handlers\Account\Payment\Stripe\DTO\CreateStripeConnectAccountDTO; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; use Throwable; @@ -26,13 +28,16 @@ public function __construct( /** * @throws Throwable */ - public function __invoke(int $accountId): JsonResponse + public function __invoke(int $accountId, Request $request): JsonResponse { $this->isActionAuthorized($accountId, AccountDomainObject::class, Role::ADMIN); try { - $accountResult = $this->createStripeConnectAccountHandler->handle(CreateStripeConnectAccountDTO::fromArray([ + $accountResult = $this->createStripeConnectAccountHandler->handle(CreateStripeConnectAccountDTO::from([ 'accountId' => $this->getAuthenticatedAccountId(), + 'platform' => $request->has('platform') + ? StripePlatform::from($request->get('platform')) + : null, ])); } catch (CreateStripeConnectAccountLinksFailedException|CreateStripeConnectAccountFailedException $e) { return $this->errorResponse( diff --git a/backend/app/Http/Actions/Accounts/Stripe/GetStripeConnectAccountsAction.php b/backend/app/Http/Actions/Accounts/Stripe/GetStripeConnectAccountsAction.php new file mode 100644 index 0000000000..e1f607d926 --- /dev/null +++ b/backend/app/Http/Actions/Accounts/Stripe/GetStripeConnectAccountsAction.php @@ -0,0 +1,34 @@ +isActionAuthorized($accountId, AccountDomainObject::class, Role::ADMIN); + + $result = $this->getStripeConnectAccountsHandler->handle($accountId); + + return $this->resourceResponse( + resource: StripeConnectAccountsResponseResource::class, + data: $result, + ); + } +} diff --git a/backend/app/Http/Actions/Attendees/ExportAttendeesAction.php b/backend/app/Http/Actions/Attendees/ExportAttendeesAction.php index 65789f6a61..7ceaa127d5 100644 --- a/backend/app/Http/Actions/Attendees/ExportAttendeesAction.php +++ b/backend/app/Http/Actions/Attendees/ExportAttendeesAction.php @@ -5,6 +5,7 @@ use HiEvents\DomainObjects\AttendeeCheckInDomainObject; use HiEvents\DomainObjects\Enums\QuestionBelongsTo; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\DomainObjects\QuestionAndAnswerViewDomainObject; @@ -48,15 +49,29 @@ public function __invoke(int $eventId): BinaryFileResponse ], name: 'product' )) + ->loadRelation(new Relationship( + domainObject: OrderDomainObject::class, + nested: [ + new Relationship( + domainObject: QuestionAndAnswerViewDomainObject::class + ) + ], + name: 'order' + )) ->findByEventIdForExport($eventId); - $questions = $this->questionRepository->findWhere([ + $productQuestions = $this->questionRepository->findWhere([ 'event_id' => $eventId, 'belongs_to' => QuestionBelongsTo::PRODUCT->name, ]); + $orderQuestions = $this->questionRepository->findWhere([ + 'event_id' => $eventId, + 'belongs_to' => QuestionBelongsTo::ORDER->name, + ]); + return Excel::download( - $this->export->withData($attendees, $questions), + $this->export->withData($attendees, $productQuestions, $orderQuestions), 'attendees.xlsx' ); } diff --git a/backend/app/Http/Actions/BaseAction.php b/backend/app/Http/Actions/BaseAction.php index c3c1b2a611..78eb0675c8 100644 --- a/backend/app/Http/Actions/BaseAction.php +++ b/backend/app/Http/Actions/BaseAction.php @@ -4,6 +4,7 @@ namespace HiEvents\Http\Actions; +use HiEvents\DataTransferObjects\BaseDataObject; use HiEvents\DataTransferObjects\BaseDTO; use HiEvents\DomainObjects\Enums\Role; use HiEvents\DomainObjects\Interfaces\DomainObjectInterface; @@ -26,6 +27,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Response; +use Spatie\LaravelData\Data; abstract class BaseAction extends Controller { @@ -70,12 +72,12 @@ protected function filterableResourceResponse( * @return JsonResponse */ protected function resourceResponse( - string $resource, - Collection|DomainObjectInterface|LengthAwarePaginator|BaseDTO|Paginator $data, - int $statusCode = ResponseCodes::HTTP_OK, - array $meta = [], - array $headers = [], - array $errors = [], + string $resource, + Collection|DomainObjectInterface|LengthAwarePaginator|BaseDTO|Paginator|BaseDataObject $data, + int $statusCode = ResponseCodes::HTTP_OK, + array $meta = [], + array $headers = [], + array $errors = [], ): JsonResponse { if ($data instanceof Collection || $data instanceof Paginator) { @@ -128,8 +130,8 @@ protected function errorResponse( protected function jsonResponse( mixed $data, - int $statusCode = ResponseCodes::HTTP_OK, - bool $wrapInData = false, + int $statusCode = ResponseCodes::HTTP_OK, + bool $wrapInData = false, ): JsonResponse { if ($wrapInData) { diff --git a/backend/app/Http/Actions/EmailTemplates/BaseEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/BaseEmailTemplateAction.php new file mode 100644 index 0000000000..d1af220eff --- /dev/null +++ b/backend/app/Http/Actions/EmailTemplates/BaseEmailTemplateAction.php @@ -0,0 +1,66 @@ +validate([ + 'template_type' => ['required', new Enum(EmailTemplateType::class)], + 'subject' => ['required', 'string', 'max:255'], + 'body' => ['required', 'string'], + 'ctaLabel' => ['required', 'string', 'max:100'], + 'isActive' => ['boolean'], + ]); + } + + protected function validateUpdateEmailTemplateRequest(Request $request): array + { + return $request->validate([ + 'subject' => ['required', 'string', 'max:255'], + 'body' => ['required', 'string'], + 'ctaLabel' => ['required', 'string', 'max:100'], + 'isActive' => ['boolean'], + ]); + } + + protected function validatePreviewRequest(Request $request): array + { + return $request->validate([ + 'template_type' => ['required', new Enum(EmailTemplateType::class)], + 'subject' => ['required', 'string', 'max:255'], + 'body' => ['required', 'string'], + 'ctaLabel' => ['required', 'string', 'max:100'], + ]); + } + + protected function handlePreviewRequest(Request $request, PreviewEmailTemplateHandler $handler): JsonResponse + { + $validated = $this->validatePreviewRequest($request); + + $cta = [ + 'label' => $validated['ctaLabel'], + 'url_token' => $validated['template_type'] === 'order_confirmation' ? 'order.url' : 'ticket.url', + ]; + + $preview = $handler->handle( + new PreviewEmailTemplateDTO( + subject: $validated['subject'], + body: $validated['body'], + template_type: EmailTemplateType::from($validated['template_type']), + cta: $cta, + ) + ); + + return $this->jsonResponse($preview); + } +} diff --git a/backend/app/Http/Actions/EmailTemplates/CreateEventEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/CreateEventEmailTemplateAction.php new file mode 100644 index 0000000000..623628d9d2 --- /dev/null +++ b/backend/app/Http/Actions/EmailTemplates/CreateEventEmailTemplateAction.php @@ -0,0 +1,67 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $validated = $this->validateEmailTemplateRequest($request); + + try { + $cta = [ + 'label' => $validated['ctaLabel'], + 'url_token' => $validated['template_type'] === 'order_confirmation' ? 'order.url' : 'ticket.url', + ]; + + $template = $this->handler->handle( + new UpsertEmailTemplateDTO( + account_id: $this->getAuthenticatedAccountId(), + template_type: EmailTemplateType::from($validated['template_type']), + subject: $validated['subject'], + body: $validated['body'], + organizer_id: null, + event_id: $eventId, + cta: $cta, + is_active: $validated['isActive'] ?? true, + ) + ); + } catch (EmailTemplateValidationException $e) { + throw ValidationException::withMessages($e->validationErrors ?: ['body' => $e->getMessage()]); + } catch (ResourceConflictException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: ResponseCodes::HTTP_CONFLICT, + ); + } + + return $this->resourceResponse( + resource: EmailTemplateResource::class, + data: $template, + statusCode: ResponseCodes::HTTP_CREATED + ); + } +} diff --git a/backend/app/Http/Actions/EmailTemplates/CreateOrganizerEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/CreateOrganizerEmailTemplateAction.php new file mode 100644 index 0000000000..cd89b0de05 --- /dev/null +++ b/backend/app/Http/Actions/EmailTemplates/CreateOrganizerEmailTemplateAction.php @@ -0,0 +1,67 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class); + + $validated = $this->validateEmailTemplateRequest($request); + + try { + $cta = [ + 'label' => $validated['ctaLabel'], + 'url_token' => $validated['template_type'] === 'order_confirmation' ? 'order.url' : 'ticket.url', + ]; + + $template = $this->handler->handle( + new UpsertEmailTemplateDTO( + account_id: $this->getAuthenticatedAccountId(), + template_type: EmailTemplateType::from($validated['template_type']), + subject: $validated['subject'], + body: $validated['body'], + organizer_id: $organizerId, + event_id: null, + cta: $cta, + is_active: $validated['isActive'] ?? true, + ) + ); + } catch (EmailTemplateValidationException $e) { + throw ValidationException::withMessages($e->validationErrors ?: ['body' => $e->getMessage()]); + } catch (ResourceConflictException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: ResponseCodes::HTTP_CONFLICT, + ); + } + + return $this->resourceResponse( + resource: EmailTemplateResource::class, + data: $template, + statusCode: ResponseCodes::HTTP_CREATED + ); + } +} diff --git a/backend/app/Http/Actions/EmailTemplates/DeleteEventEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/DeleteEventEmailTemplateAction.php new file mode 100644 index 0000000000..ed9a42b7dc --- /dev/null +++ b/backend/app/Http/Actions/EmailTemplates/DeleteEventEmailTemplateAction.php @@ -0,0 +1,42 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + try { + $this->handler->handle( + new DeleteEmailTemplateDTO( + id: $templateId, + account_id: $this->getAuthenticatedAccountId(), + ) + ); + } catch (EmailTemplateNotFoundException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: ResponseCodes::HTTP_NOT_FOUND, + ); + } + + return $this->deletedResponse(); + } +} diff --git a/backend/app/Http/Actions/EmailTemplates/DeleteOrganizerEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/DeleteOrganizerEmailTemplateAction.php new file mode 100644 index 0000000000..e24b17236c --- /dev/null +++ b/backend/app/Http/Actions/EmailTemplates/DeleteOrganizerEmailTemplateAction.php @@ -0,0 +1,40 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class); + + try { + $this->handler->handle( + new DeleteEmailTemplateDTO( + id: $templateId, + account_id: $this->getAuthenticatedAccountId(), + ) + ); + } catch (EmailTemplateNotFoundException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: ResponseCodes::HTTP_NOT_FOUND, + ); + } + + return response()->json(['message' => 'Template deleted successfully'], ResponseCodes::HTTP_OK); + } +} \ No newline at end of file diff --git a/backend/app/Http/Actions/EmailTemplates/GetAvailableTokensAction.php b/backend/app/Http/Actions/EmailTemplates/GetAvailableTokensAction.php new file mode 100644 index 0000000000..b3568dba7e --- /dev/null +++ b/backend/app/Http/Actions/EmailTemplates/GetAvailableTokensAction.php @@ -0,0 +1,32 @@ +jsonResponse(['error' => __('Invalid template type')], ResponseCodes::HTTP_BAD_REQUEST); + } + + $tokens = $this->handler->handle($type); + + return $this->jsonResponse(['tokens' => $tokens]); + } +} diff --git a/backend/app/Http/Actions/EmailTemplates/GetDefaultEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/GetDefaultEmailTemplateAction.php new file mode 100644 index 0000000000..565a7c28c5 --- /dev/null +++ b/backend/app/Http/Actions/EmailTemplates/GetDefaultEmailTemplateAction.php @@ -0,0 +1,35 @@ +emailTemplateService->getDefaultTemplate($type); + $defaults[$type->value] = [ + 'type' => $type->value, + 'subject' => $template['subject'], + 'body' => $template['body'], + 'cta' => $template['cta'] ?? null, + ]; + } + + return $this->jsonResponse($defaults); + } +} diff --git a/backend/app/Http/Actions/EmailTemplates/GetEventEmailTemplatesAction.php b/backend/app/Http/Actions/EmailTemplates/GetEventEmailTemplatesAction.php new file mode 100644 index 0000000000..7bacc87ab0 --- /dev/null +++ b/backend/app/Http/Actions/EmailTemplates/GetEventEmailTemplatesAction.php @@ -0,0 +1,49 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $validated = $request->validate([ + 'template_type' => ['nullable', new Enum(EmailTemplateType::class)], + 'include_inactive' => ['in:true,false'], + ]); + + $templates = $this->handler->handle( + new GetEmailTemplatesDTO( + account_id: $this->getAuthenticatedAccountId(), + organizer_id: null, + event_id: $eventId, + template_type: isset($validated['template_type']) + ? EmailTemplateType::from($validated['template_type']) + : null, + include_inactive: $validated['include_inactive'] ?? false, + ) + ); + + return $this->resourceResponse( + resource: EmailTemplateResource::class, + data: $templates, + ); + } +} diff --git a/backend/app/Http/Actions/EmailTemplates/GetOrganizerEmailTemplatesAction.php b/backend/app/Http/Actions/EmailTemplates/GetOrganizerEmailTemplatesAction.php new file mode 100644 index 0000000000..075156488d --- /dev/null +++ b/backend/app/Http/Actions/EmailTemplates/GetOrganizerEmailTemplatesAction.php @@ -0,0 +1,48 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class); + + $validated = $request->validate([ + 'template_type' => ['nullable', new Enum(EmailTemplateType::class)], + 'include_inactive' => ['in:true,false'], + ]); + + $templates = $this->handler->handle( + new GetEmailTemplatesDTO( + account_id: $this->getAuthenticatedAccountId(), + organizer_id: $organizerId, + event_id: null, + template_type: isset($validated['template_type']) + ? EmailTemplateType::from($validated['template_type']) + : null, + include_inactive: $validated['include_inactive'] ?? false, + ) + ); + + return $this->resourceResponse( + resource: EmailTemplateResource::class, + data: $templates, + ); + } +} diff --git a/backend/app/Http/Actions/EmailTemplates/PreviewEventEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/PreviewEventEmailTemplateAction.php new file mode 100644 index 0000000000..a9a3c27113 --- /dev/null +++ b/backend/app/Http/Actions/EmailTemplates/PreviewEventEmailTemplateAction.php @@ -0,0 +1,24 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + return $this->handlePreviewRequest($request, $this->handler); + } +} \ No newline at end of file diff --git a/backend/app/Http/Actions/EmailTemplates/PreviewOrganizerEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/PreviewOrganizerEmailTemplateAction.php new file mode 100644 index 0000000000..8a3bfcb028 --- /dev/null +++ b/backend/app/Http/Actions/EmailTemplates/PreviewOrganizerEmailTemplateAction.php @@ -0,0 +1,24 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class); + + return $this->handlePreviewRequest($request, $this->handler); + } +} \ No newline at end of file diff --git a/backend/app/Http/Actions/EmailTemplates/UpdateEventEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/UpdateEventEmailTemplateAction.php new file mode 100644 index 0000000000..908f879345 --- /dev/null +++ b/backend/app/Http/Actions/EmailTemplates/UpdateEventEmailTemplateAction.php @@ -0,0 +1,72 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $validated = $this->validateUpdateEmailTemplateRequest($request); + + try { + $cta = [ + 'label' => $validated['ctaLabel'], + 'url_token' => 'order.url', // This will be determined by template type during update + ]; + + $template = $this->handler->handle( + new UpsertEmailTemplateDTO( + account_id: $this->getAuthenticatedAccountId(), + template_type: EmailTemplateType::ORDER_CONFIRMATION, // This will be ignored in update + subject: $validated['subject'], + body: $validated['body'], + organizer_id: null, + event_id: $eventId, + id: $templateId, + cta: $cta, + is_active: $validated['isActive'] ?? true, + ) + ); + } catch (EmailTemplateValidationException $e) { + throw ValidationException::withMessages($e->validationErrors ?: ['body' => $e->getMessage()]); + } catch (InvalidEmailTemplateException $e) { + throw ValidationException::withMessages([ + 'id' => $e->getMessage(), + ]); + } catch (EmailTemplateNotFoundException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: ResponseCodes::HTTP_NOT_FOUND, + ); + } + + return $this->resourceResponse( + resource: EmailTemplateResource::class, + data: $template, + ); + } +} diff --git a/backend/app/Http/Actions/EmailTemplates/UpdateOrganizerEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/UpdateOrganizerEmailTemplateAction.php new file mode 100644 index 0000000000..8d767fa568 --- /dev/null +++ b/backend/app/Http/Actions/EmailTemplates/UpdateOrganizerEmailTemplateAction.php @@ -0,0 +1,71 @@ +isActionAuthorized($organizerId, OrganizerDomainObject::class); + + $validated = $this->validateUpdateEmailTemplateRequest($request); + + try { + $cta = [ + 'label' => $validated['ctaLabel'], + 'url_token' => 'order.url', // This will be determined by template type during update + ]; + + $template = $this->handler->handle( + new UpsertEmailTemplateDTO( + account_id: $this->getAuthenticatedAccountId(), + template_type: EmailTemplateType::ORDER_CONFIRMATION, // This will be ignored in update + subject: $validated['subject'], + body: $validated['body'], + organizer_id: $organizerId, + event_id: null, + id: $templateId, + cta: $cta, + is_active: $validated['isActive'] ?? true, + ) + ); + } catch (EmailTemplateValidationException $e) { + throw ValidationException::withMessages($e->validationErrors ?: ['body' => $e->getMessage()]); + } catch (InvalidEmailTemplateException $e) { + throw ValidationException::withMessages([ + 'id' => $e->getMessage(), + ]); + } catch (EmailTemplateNotFoundException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: ResponseCodes::HTTP_NOT_FOUND, + ); + } + + return $this->resourceResponse( + resource: EmailTemplateResource::class, + data: $template, + ); + } +} diff --git a/backend/app/Http/Actions/Events/DuplicateEventAction.php b/backend/app/Http/Actions/Events/DuplicateEventAction.php index 5b424ea69f..160328fd27 100644 --- a/backend/app/Http/Actions/Events/DuplicateEventAction.php +++ b/backend/app/Http/Actions/Events/DuplicateEventAction.php @@ -36,6 +36,7 @@ public function __invoke(int $eventId, DuplicateEventRequest $request): JsonResp duplicateCapacityAssignments: $request->validated('duplicate_capacity_assignments'), duplicateCheckInLists: $request->validated('duplicate_check_in_lists'), duplicateEventCoverImage: $request->validated('duplicate_event_cover_image'), + duplicateTicketLogo: $request->validated('duplicate_ticket_logo'), duplicateWebhooks: $request->validated('duplicate_webhooks'), duplicateAffiliates: $request->validated('duplicate_affiliates'), description: $request->validated('description'), diff --git a/backend/app/Http/Actions/Events/GetEventAction.php b/backend/app/Http/Actions/Events/GetEventAction.php index f1ca761169..5df8dd1ccb 100644 --- a/backend/app/Http/Actions/Events/GetEventAction.php +++ b/backend/app/Http/Actions/Events/GetEventAction.php @@ -5,6 +5,7 @@ namespace HiEvents\Http\Actions\Events; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\ImageDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\ProductCategoryDomainObject; use HiEvents\DomainObjects\TaxAndFeesDomainObject; @@ -31,6 +32,7 @@ public function __invoke(int $eventId): JsonResponse $event = $this->eventRepository ->loadRelation(new Relationship(domainObject: OrganizerDomainObject::class, name: 'organizer')) + ->loadRelation(new Relationship(ImageDomainObject::class)) ->loadRelation( new Relationship(ProductCategoryDomainObject::class, [ new Relationship(ProductDomainObject::class, [ diff --git a/backend/app/Http/Actions/Events/Images/GetEventImagesAction.php b/backend/app/Http/Actions/Events/Images/GetEventImagesAction.php index 208a15dd0f..c32b7b0ad8 100644 --- a/backend/app/Http/Actions/Events/Images/GetEventImagesAction.php +++ b/backend/app/Http/Actions/Events/Images/GetEventImagesAction.php @@ -2,7 +2,6 @@ namespace HiEvents\Http\Actions\Events\Images; -use HiEvents\DomainObjects\Enums\ImageType; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Http\Actions\BaseAction; use HiEvents\Repository\Interfaces\ImageRepositoryInterface; @@ -22,7 +21,6 @@ public function __invoke(int $eventId): JsonResponse $images = $this->imageRepository->findWhere([ 'entity_id' => $eventId, 'entity_type' => EventDomainObject::class, - 'type' => ImageType::EVENT_COVER->name, ]); return $this->resourceResponse(ImageResource::class, $images); diff --git a/backend/app/Http/Actions/Orders/CancelOrderAction.php b/backend/app/Http/Actions/Orders/CancelOrderAction.php index d4853da991..67cf6f6e1a 100644 --- a/backend/app/Http/Actions/Orders/CancelOrderAction.php +++ b/backend/app/Http/Actions/Orders/CancelOrderAction.php @@ -4,14 +4,19 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\Status\OrderStatus; +use HiEvents\Exceptions\RefundNotPossibleException; use HiEvents\Exceptions\ResourceConflictException; use HiEvents\Http\Actions\BaseAction; use HiEvents\Resources\Order\OrderResource; use HiEvents\Services\Application\Handlers\Order\CancelOrderHandler; use HiEvents\Services\Application\Handlers\Order\DTO\CancelOrderDTO; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Validation\ValidationException; +use Stripe\Exception\ApiErrorException; use Symfony\Component\HttpFoundation\Response as HttpResponse; +use Throwable; class CancelOrderAction extends BaseAction { @@ -21,14 +26,28 @@ public function __construct( { } - public function __invoke(int $eventId, int $orderId): JsonResponse|Response + /** + * @throws Throwable + * @throws ValidationException + */ + public function __invoke(int $eventId, int $orderId, Request $request): JsonResponse|Response { $this->isActionAuthorized($eventId, EventDomainObject::class); try { - $order = $this->cancelOrderHandler->handle(new CancelOrderDTO($eventId, $orderId)); + $order = $this->cancelOrderHandler->handle(new CancelOrderDTO( + eventId: $eventId, + orderId: $orderId, + refund: $request->boolean('refund') + )); } catch (ResourceConflictException $e) { return $this->errorResponse($e->getMessage(), HttpResponse::HTTP_CONFLICT); + } catch (ApiErrorException|RefundNotPossibleException $exception) { + throw ValidationException::withMessages([ + 'refund' => $exception instanceof ApiErrorException + ? 'Stripe error: ' . $exception->getMessage() + : $exception->getMessage(), + ]); } return $this->resourceResponse(OrderResource::class, $order->setStatus(OrderStatus::CANCELLED->name)); diff --git a/backend/app/Http/Actions/Orders/Payment/Stripe/CreatePaymentIntentActionPublic.php b/backend/app/Http/Actions/Orders/Payment/Stripe/CreatePaymentIntentActionPublic.php index 1e3ee468ec..bd2ea51c78 100644 --- a/backend/app/Http/Actions/Orders/Payment/Stripe/CreatePaymentIntentActionPublic.php +++ b/backend/app/Http/Actions/Orders/Payment/Stripe/CreatePaymentIntentActionPublic.php @@ -10,11 +10,10 @@ class CreatePaymentIntentActionPublic extends BaseAction { - private CreatePaymentIntentHandler $createPaymentIntentHandler; - - public function __construct(CreatePaymentIntentHandler $createPaymentIntentHandler) + public function __construct( + private readonly CreatePaymentIntentHandler $createPaymentIntentHandler, + ) { - $this->createPaymentIntentHandler = $createPaymentIntentHandler; } public function __invoke(int $eventId, string $orderShortId): JsonResponse @@ -28,6 +27,8 @@ public function __invoke(int $eventId, string $orderShortId): JsonResponse return $this->jsonResponse([ 'client_secret' => $createIntent->clientSecret, 'account_id' => $createIntent->accountId, + 'public_key' => $createIntent->publicKey, + 'stripe_platform' => $createIntent->stripePlatform?->value, ]); } } diff --git a/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php b/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php index 76c08cf9ec..c38c6cbac3 100644 --- a/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php +++ b/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php @@ -13,6 +13,7 @@ use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; +use HiEvents\Services\Domain\Email\MailBuilderService; use Illuminate\Http\Response; use Illuminate\Mail\Mailer; @@ -22,6 +23,7 @@ public function __construct( private readonly EventRepositoryInterface $eventRepository, private readonly OrderRepositoryInterface $orderRepository, private readonly Mailer $mailer, + private readonly MailBuilderService $mailBuilderService, ) { } @@ -51,16 +53,18 @@ public function __invoke(int $eventId, int $orderId): Response ->loadRelation(new Relationship(EventSettingDomainObject::class)) ->findById($order->getEventId()); + $mail = $this->mailBuilderService->buildOrderSummaryMail( + $order, + $event, + $event->getEventSettings(), + $event->getOrganizer(), + $order->getLatestInvoice() + ); + $this->mailer ->to($order->getEmail()) ->locale($order->getLocale()) - ->send(new OrderSummary( - order: $order, - event: $event, - organizer: $event->getOrganizer(), - eventSettings: $event->getEventSettings(), - invoice: $order->getLatestInvoice(), - )); + ->send($mail); } return $this->noContentResponse(); diff --git a/backend/app/Http/Actions/Organizers/Public/SendOrganizerContactMessagePublicAction.php b/backend/app/Http/Actions/Organizers/Public/SendOrganizerContactMessagePublicAction.php index 4addad71a0..d1251a3f70 100644 --- a/backend/app/Http/Actions/Organizers/Public/SendOrganizerContactMessagePublicAction.php +++ b/backend/app/Http/Actions/Organizers/Public/SendOrganizerContactMessagePublicAction.php @@ -30,7 +30,7 @@ public function __invoke(Request $request, int $organizerId): JsonResponse $this->handler->handle(SendOrganizerContactMessageDTO::from([ 'organizer_id' => $organizerId, - 'account_id' => $this->getAuthenticatedAccountId(), + 'account_id' => $this->isUserAuthenticated() ? $this->getAuthenticatedAccountId() : null, 'name' => $data['name'], 'email' => $data['email'], 'message' => $data['message'], diff --git a/backend/app/Http/Request/Attendee/CreateAttendeeRequest.php b/backend/app/Http/Request/Attendee/CreateAttendeeRequest.php index 3ff1502dfe..c73fb80acf 100644 --- a/backend/app/Http/Request/Attendee/CreateAttendeeRequest.php +++ b/backend/app/Http/Request/Attendee/CreateAttendeeRequest.php @@ -15,8 +15,8 @@ public function rules(): array 'product_id' => ['int', 'required'], 'product_price_id' => ['int', 'nullable', 'required'], 'email' => ['required', 'email'], - 'first_name' => 'string|required', - 'last_name' => 'string', + 'first_name' => ['string', 'required', 'max:40'], + 'last_name' => ['string', 'max:40'], 'amount_paid' => ['required', ...RulesHelper::MONEY], 'send_confirmation_email' => ['required', 'boolean'], 'taxes_and_fees' => ['array'], diff --git a/backend/app/Http/Request/Event/DuplicateEventRequest.php b/backend/app/Http/Request/Event/DuplicateEventRequest.php index 8a6e11f5a0..26959d7aea 100644 --- a/backend/app/Http/Request/Event/DuplicateEventRequest.php +++ b/backend/app/Http/Request/Event/DuplicateEventRequest.php @@ -23,6 +23,7 @@ public function rules(): array 'duplicate_event_cover_image' => ['boolean', 'required'], 'duplicate_webhooks' => ['boolean', 'required'], 'duplicate_affiliates' => ['boolean', 'required'], + 'duplicate_ticket_logo' => ['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 74416cbaf4..8dcf38728f 100644 --- a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php +++ b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php @@ -75,6 +75,14 @@ public function rules(): array 'invoice_tax_details' => ['nullable', 'string'], 'invoice_notes' => ['nullable', 'string'], 'invoice_payment_terms_days' => ['nullable', 'integer', 'gte:0', 'lte:1000'], + + // Ticket design settings + 'ticket_design_settings' => ['nullable', 'array'], + 'ticket_design_settings.accent_color' => ['nullable', 'string', ...RulesHelper::HEX_COLOR], + 'ticket_design_settings.logo_image_id' => ['nullable', 'integer'], + 'ticket_design_settings.footer_text' => ['nullable', 'string', 'max:500'], + 'ticket_design_settings.layout_type' => ['nullable', 'string', Rule::in(['default', 'modern'])], + 'ticket_design_settings.enabled' => ['boolean'], ]; } @@ -106,6 +114,11 @@ public function messages(): array '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.'), + + // Ticket design messages + 'ticket_design_settings.accent_color' => $colorMessage, + 'ticket_design_settings.footer_text.max' => __('The footer text may not be greater than 500 characters.'), + 'ticket_design_settings.layout_type.in' => __('The layout type must be default or modern.'), ]; } } diff --git a/backend/app/Http/Resources/EmailTemplateResource.php b/backend/app/Http/Resources/EmailTemplateResource.php new file mode 100644 index 0000000000..5fd81eac2a --- /dev/null +++ b/backend/app/Http/Resources/EmailTemplateResource.php @@ -0,0 +1,29 @@ + $this->getId(), + 'account_id' => $this->getAccountId(), + 'organizer_id' => $this->getOrganizerId(), + 'event_id' => $this->getEventId(), + 'template_type' => $this->getTemplateType(), + 'subject' => $this->getSubject(), + 'body' => $this->getBody(), + 'cta' => $this->getCta(), + 'engine' => $this->getEngine(), + 'is_active' => $this->getIsActive(), + ]; + } +} diff --git a/backend/app/Jobs/Event/UpdateEventStatisticsJob.php b/backend/app/Jobs/Event/UpdateEventStatisticsJob.php index 1189b4ef3d..3101501a35 100644 --- a/backend/app/Jobs/Event/UpdateEventStatisticsJob.php +++ b/backend/app/Jobs/Event/UpdateEventStatisticsJob.php @@ -4,7 +4,7 @@ use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\Exceptions\EventStatisticsVersionMismatchException; -use HiEvents\Services\Domain\EventStatistics\EventStatisticsUpdateService; +use HiEvents\Services\Domain\EventStatistics\EventStatisticsIncrementService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -30,9 +30,9 @@ public function __construct(OrderDomainObject $order) /** * @throws EventStatisticsVersionMismatchException|Throwable */ - public function handle(EventStatisticsUpdateService $service): void + public function handle(EventStatisticsIncrementService $service): void { - $service->updateStatistics($this->order); + $service->incrementForOrder($this->order); } public function failed(Throwable $exception): void diff --git a/backend/app/Locale.php b/backend/app/Locale.php index 1292337d83..3d45a7dcbf 100644 --- a/backend/app/Locale.php +++ b/backend/app/Locale.php @@ -21,6 +21,8 @@ enum Locale: string case ZH_HK = 'zh-hk'; case VI = 'vi'; + case TR = 'tr'; + public static function getSupportedLocales(): array { return self::valuesArray(); diff --git a/backend/app/Mail/Attendee/AttendeeTicketMail.php b/backend/app/Mail/Attendee/AttendeeTicketMail.php index f3afe2b3ec..46ae3d8abc 100644 --- a/backend/app/Mail/Attendee/AttendeeTicketMail.php +++ b/backend/app/Mail/Attendee/AttendeeTicketMail.php @@ -11,6 +11,7 @@ use HiEvents\Helper\StringHelper; use HiEvents\Helper\Url; use HiEvents\Mail\BaseMail; +use HiEvents\Services\Domain\Email\DTO\RenderedEmailTemplateDTO; use Illuminate\Mail\Mailables\Attachment; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; @@ -23,29 +24,47 @@ */ class AttendeeTicketMail extends BaseMail { + private readonly ?RenderedEmailTemplateDTO $renderedTemplate; + public function __construct( private readonly OrderDomainObject $order, private readonly AttendeeDomainObject $attendee, private readonly EventDomainObject $event, private readonly EventSettingDomainObject $eventSettings, private readonly OrganizerDomainObject $organizer, + ?RenderedEmailTemplateDTO $renderedTemplate = null, ) { parent::__construct(); + $this->renderedTemplate = $renderedTemplate; } public function envelope(): Envelope { + $subject = $this->renderedTemplate?->subject ?? __('🎟️ Your Ticket for :event', [ + 'event' => Str::limit($this->event->getTitle(), 50) + ]); + return new Envelope( replyTo: $this->eventSettings->getSupportEmail(), - subject: __('🎟️ Your Ticket for :event', [ - 'event' => Str::limit($this->event->getTitle(), 50) - ]), + subject: $subject, ); } public function content(): Content { + if ($this->renderedTemplate) { + return new Content( + markdown: 'emails.custom-template', + with: [ + 'renderedBody' => $this->renderedTemplate->body, + 'renderedCta' => $this->renderedTemplate->cta, + 'eventSettings' => $this->eventSettings, + ] + ); + } + + // If no template is provided, use the default blade template return new Content( markdown: 'emails.orders.attendee-ticket', with: [ diff --git a/backend/app/Mail/Order/OrderSummary.php b/backend/app/Mail/Order/OrderSummary.php index 97b041375e..4e6f1b838d 100644 --- a/backend/app/Mail/Order/OrderSummary.php +++ b/backend/app/Mail/Order/OrderSummary.php @@ -10,6 +10,7 @@ use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Helper\Url; use HiEvents\Mail\BaseMail; +use HiEvents\Services\Domain\Email\DTO\RenderedEmailTemplateDTO; use Illuminate\Mail\Mailables\Attachment; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; @@ -19,27 +20,46 @@ */ class OrderSummary extends BaseMail { + private readonly ?RenderedEmailTemplateDTO $renderedTemplate; + public function __construct( private readonly OrderDomainObject $order, private readonly EventDomainObject $event, private readonly OrganizerDomainObject $organizer, private readonly EventSettingDomainObject $eventSettings, private readonly ?InvoiceDomainObject $invoice, + ?RenderedEmailTemplateDTO $renderedTemplate = null, ) { + $this->renderedTemplate = $renderedTemplate; + parent::__construct(); } public function envelope(): Envelope { + $subject = $this->renderedTemplate?->subject ?? __('Your Order is Confirmed!') . ' 🎉'; + return new Envelope( replyTo: $this->eventSettings->getSupportEmail(), - subject: __('Your Order is Confirmed!') . ' 🎉', + subject: $subject, ); } public function content(): Content { + if ($this->renderedTemplate) { + return new Content( + markdown: 'emails.custom-template', + with: [ + 'renderedBody' => $this->renderedTemplate->body, + 'renderedCta' => $this->renderedTemplate->cta, + 'eventSettings' => $this->eventSettings, + ] + ); + } + + // Fallback to original template return new Content( markdown: 'emails.orders.summary', with: [ diff --git a/backend/app/Mail/Organizer/OrganizerContactEmail.php b/backend/app/Mail/Organizer/OrganizerContactEmail.php index 941afc8126..382073bf8a 100644 --- a/backend/app/Mail/Organizer/OrganizerContactEmail.php +++ b/backend/app/Mail/Organizer/OrganizerContactEmail.php @@ -36,6 +36,9 @@ public function content(): Content 'organizerName' => $this->organizer->getName(), 'senderName' => $this->senderName, 'senderEmail' => $this->senderEmail, + 'replySubject' => urlencode(__('Response from :organizerName', [ + 'organizerName' => $this->organizer->getName(), + ])), 'messageContent' => $this->messageContent, ], ); diff --git a/backend/app/Models/Account.php b/backend/app/Models/Account.php index 1baed3b120..d7ca4af07a 100644 --- a/backend/app/Models/Account.php +++ b/backend/app/Models/Account.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; class Account extends BaseModel @@ -34,4 +35,9 @@ public function configuration(): BelongsTo foreignKey: 'account_configuration_id', ); } + + public function account_stripe_platforms(): HasMany + { + return $this->hasMany(AccountStripePlatform::class); + } } diff --git a/backend/app/Models/AccountStripePlatform.php b/backend/app/Models/AccountStripePlatform.php new file mode 100644 index 0000000000..dd9b0961ec --- /dev/null +++ b/backend/app/Models/AccountStripePlatform.php @@ -0,0 +1,23 @@ + 'array', + 'stripe_setup_completed_at' => 'datetime', + ]; + + public function account(): BelongsTo + { + return $this->belongsTo(Account::class); + } +} \ No newline at end of file diff --git a/backend/app/Models/EmailTemplate.php b/backend/app/Models/EmailTemplate.php new file mode 100644 index 0000000000..a0e5b8e5a1 --- /dev/null +++ b/backend/app/Models/EmailTemplate.php @@ -0,0 +1,55 @@ +belongsTo(Account::class); + } + + public function organizer(): BelongsTo + { + return $this->belongsTo(Organizer::class); + } + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function getCastMap(): array + { + return [ + 'cta' => 'array', + 'is_active' => 'boolean', + ]; + } + + public function getFillableFields(): array + { + return [ + 'account_id', + 'organizer_id', + 'event_id', + 'template_type', + 'subject', + 'body', + 'cta', + 'engine', + 'is_active', + ]; + } +} diff --git a/backend/app/Models/EventSetting.php b/backend/app/Models/EventSetting.php index 98ce2114fe..ebc40f2495 100644 --- a/backend/app/Models/EventSetting.php +++ b/backend/app/Models/EventSetting.php @@ -13,6 +13,7 @@ protected function getCastMap(): array return [ 'location_details' => 'array', 'payment_providers' => 'array', + 'ticket_design_settings' => 'array', ]; } } diff --git a/backend/app/Models/Order.php b/backend/app/Models/Order.php index f98301e041..5fc024b607 100644 --- a/backend/app/Models/Order.php +++ b/backend/app/Models/Order.php @@ -63,11 +63,7 @@ protected function getCastMap(): array 'point_in_time_data' => 'array', 'address' => 'array', 'taxes_and_fees_rollup' => 'array', + 'statistics_decremented_at' => 'datetime' ]; } - - protected function getFillableFields(): array - { - return []; - } } diff --git a/backend/app/Models/StripePayment.php b/backend/app/Models/StripePayment.php index 05b6ee5573..119b86ffa5 100644 --- a/backend/app/Models/StripePayment.php +++ b/backend/app/Models/StripePayment.php @@ -30,6 +30,7 @@ protected function getFillableFields(): array StripePaymentDomainObjectAbstract::PAYMENT_INTENT_ID, StripePaymentDomainObjectAbstract::PAYMENT_METHOD_ID, StripePaymentDomainObjectAbstract::CONNECTED_ACCOUNT_ID, + StripePaymentDomainObjectAbstract::STRIPE_PLATFORM, ]; } diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 7073cd658a..8f3b933a63 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -19,13 +19,15 @@ use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; use Stripe\StripeClient; +use HiEvents\Services\Infrastructure\Stripe\StripeConfigurationService; +use HiEvents\Services\Infrastructure\Stripe\StripeClientFactory; class AppServiceProvider extends ServiceProvider { public function register(): void { $this->bindDoctrineConnection(); - $this->bindStripeClient(); + $this->bindStripeServices(); $this->bindCurrencyConversionClient(); } @@ -67,8 +69,11 @@ function () { ); } - private function bindStripeClient(): void + private function bindStripeServices(): void { + $this->app->singleton(StripeConfigurationService::class); + $this->app->singleton(StripeClientFactory::class); + if (!config('services.stripe.secret_key')) { logger()?->debug('Stripe secret key is not set in the configuration file. Payment processing will not work.'); return; diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php index 2cf1bf7e74..387c4dfe4c 100644 --- a/backend/app/Providers/RepositoryServiceProvider.php +++ b/backend/app/Providers/RepositoryServiceProvider.php @@ -6,12 +6,14 @@ use HiEvents\Repository\Eloquent\AccountConfigurationRepository; use HiEvents\Repository\Eloquent\AccountRepository; +use HiEvents\Repository\Eloquent\AccountStripePlatformRepository; use HiEvents\Repository\Eloquent\AccountUserRepository; use HiEvents\Repository\Eloquent\AffiliateRepository; use HiEvents\Repository\Eloquent\AttendeeCheckInRepository; use HiEvents\Repository\Eloquent\AttendeeRepository; use HiEvents\Repository\Eloquent\CapacityAssignmentRepository; use HiEvents\Repository\Eloquent\CheckInListRepository; +use HiEvents\Repository\Eloquent\EmailTemplateRepository; use HiEvents\Repository\Eloquent\EventDailyStatisticRepository; use HiEvents\Repository\Eloquent\EventRepository; use HiEvents\Repository\Eloquent\EventSettingsRepository; @@ -43,12 +45,14 @@ use HiEvents\Repository\Eloquent\WebhookRepository; use HiEvents\Repository\Interfaces\AccountConfigurationRepositoryInterface; use HiEvents\Repository\Interfaces\AccountRepositoryInterface; +use HiEvents\Repository\Interfaces\AccountStripePlatformRepositoryInterface; use HiEvents\Repository\Interfaces\AccountUserRepositoryInterface; use HiEvents\Repository\Interfaces\AffiliateRepositoryInterface; use HiEvents\Repository\Interfaces\AttendeeCheckInRepositoryInterface; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\CapacityAssignmentRepositoryInterface; use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface; +use HiEvents\Repository\Interfaces\EmailTemplateRepositoryInterface; use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface; @@ -123,6 +127,8 @@ class RepositoryServiceProvider extends ServiceProvider QuestionAndAnswerViewRepositoryInterface::class => QuestionAndAnswerViewRepository::class, OutgoingMessageRepositoryInterface::class => OutgoingMessageRepository::class, OrganizerSettingsRepositoryInterface::class => OrganizerSettingsRepository::class, + EmailTemplateRepositoryInterface::class => EmailTemplateRepository::class, + AccountStripePlatformRepositoryInterface::class => AccountStripePlatformRepository::class, ]; public function register(): void diff --git a/backend/app/Repository/Eloquent/AccountRepository.php b/backend/app/Repository/Eloquent/AccountRepository.php index a51a3e3202..d7d066147e 100644 --- a/backend/app/Repository/Eloquent/AccountRepository.php +++ b/backend/app/Repository/Eloquent/AccountRepository.php @@ -24,6 +24,7 @@ public function findByEventId(int $eventId): AccountDomainObject { $account = $this ->model + ->select('accounts.*') ->join('events', 'accounts.id', '=', 'events.account_id') ->where('events.id', $eventId) ->first(); diff --git a/backend/app/Repository/Eloquent/AccountStripePlatformRepository.php b/backend/app/Repository/Eloquent/AccountStripePlatformRepository.php new file mode 100644 index 0000000000..05a5ffcd0b --- /dev/null +++ b/backend/app/Repository/Eloquent/AccountStripePlatformRepository.php @@ -0,0 +1,22 @@ +applyConditions($conditions); + $count = $this->model->count(); + $this->resetModel(); + + return $count; + } + public function loadRelation(string|Relationship $relationship): static { if (is_string($relationship)) { diff --git a/backend/app/Repository/Eloquent/EmailTemplateRepository.php b/backend/app/Repository/Eloquent/EmailTemplateRepository.php new file mode 100644 index 0000000000..cd8cab8f96 --- /dev/null +++ b/backend/app/Repository/Eloquent/EmailTemplateRepository.php @@ -0,0 +1,92 @@ +findByTypeAndScope($type, $accountId, $eventId); + if ($template) { + return $template; + } + } + + // Try organizer-specific template as fallback + if ($organizerId) { + $template = $this->findByTypeAndScope($type, $accountId, null, $organizerId); + if ($template) { + return $template; + } + } + + // No custom template found - AttendeeTicketMail and OrderSummary will use their default templates + return null; + } + + public function findByEvent(int $eventId): Collection + { + return $this->findWhere([ + 'event_id' => $eventId, + 'is_active' => true, + ]); + } + + public function findByOrganizer(int $organizerId): Collection + { + return $this->findWhere([ + 'organizer_id' => $organizerId, + 'event_id' => null, + 'is_active' => true, + ]); + } + + public function findByTypeAndScope( + EmailTemplateType $type, + int $accountId, + ?int $eventId = null, + ?int $organizerId = null + ): ?EmailTemplateDomainObject { + $conditions = [ + 'account_id' => $accountId, + 'template_type' => $type->value, + 'is_active' => true, + ]; + + if ($eventId) { + $conditions['event_id'] = $eventId; + } else { + $conditions[] = ['event_id', '=', null]; + } + + if ($organizerId) { + $conditions['organizer_id'] = $organizerId; + } else { + $conditions[] = ['organizer_id', '=', null]; + } + + return $this->findFirstWhere($conditions); + } +} diff --git a/backend/app/Repository/Interfaces/AccountStripePlatformRepositoryInterface.php b/backend/app/Repository/Interfaces/AccountStripePlatformRepositoryInterface.php new file mode 100644 index 0000000000..9d78198092 --- /dev/null +++ b/backend/app/Repository/Interfaces/AccountStripePlatformRepositoryInterface.php @@ -0,0 +1,9 @@ + + */ +interface EmailTemplateRepositoryInterface extends RepositoryInterface +{ + /** + * Find a template by type, with fallback logic + * First tries event-specific, then organizer-specific, then system default + */ + public function findByTypeWithFallback( + EmailTemplateType $type, + int $accountId, + ?int $eventId = null, + ?int $organizerId = null + ): ?EmailTemplateDomainObject; + + /** + * Find all templates for an event + */ + public function findByEvent(int $eventId): Collection; + + /** + * Find all templates for an organizer + */ + public function findByOrganizer(int $organizerId): Collection; + + /** + * Find a specific template + */ + public function findByTypeAndScope( + EmailTemplateType $type, + int $accountId, + ?int $eventId = null, + ?int $organizerId = null + ): ?EmailTemplateDomainObject; +} \ No newline at end of file diff --git a/backend/app/Repository/Interfaces/RepositoryInterface.php b/backend/app/Repository/Interfaces/RepositoryInterface.php index 13783497f2..073ab60c36 100644 --- a/backend/app/Repository/Interfaces/RepositoryInterface.php +++ b/backend/app/Repository/Interfaces/RepositoryInterface.php @@ -71,7 +71,7 @@ public function paginateWhere( */ public function simplePaginateWhere( array $where, - ?int $limit = null, + ?int $limit = null, array $columns = self::DEFAULT_COLUMNS, ): Paginator; @@ -128,9 +128,9 @@ public function findFirstWhere(array $where, array $columns = self::DEFAULT_COLU * @return T|null */ public function findFirstByField( - string $field, + string $field, ?string $value = null, - array $columns = ['*'] + array $columns = ['*'] ): ?DomainObjectInterface; /** @@ -198,6 +198,8 @@ public function decrementEach(array $where, array $columns, array $extra = []): public function incrementEach(array $columns, array $additionalUpdates = [], ?array $where = null); + public function countWhere(array $conditions): int; + public function includeDeleted(): static; public function loadRelation(string|Relationship $relationship): static; diff --git a/backend/app/Resources/Account/AccountResource.php b/backend/app/Resources/Account/AccountResource.php index 994c4bcf57..dcc55f687f 100644 --- a/backend/app/Resources/Account/AccountResource.php +++ b/backend/app/Resources/Account/AccountResource.php @@ -13,6 +13,9 @@ class AccountResource extends JsonResource { public function toArray(Request $request): array { + $activeStripePlatform = $this->getPrimaryStripePlatform(); + $isHiEvents = config('app.is_hi_events', false); + return [ 'id' => $this->getId(), 'name' => $this->getName(), @@ -23,10 +26,16 @@ public function toArray(Request $request): array 'is_account_email_confirmed' => $this->getAccountVerifiedAt() !== null, 'is_saas_mode_enabled' => config('app.saas_mode_enabled'), - $this->mergeWhen(config('app.saas_mode_enabled'), [ - 'stripe_account_id' => $this->getStripeAccountId(), - 'stripe_connect_setup_complete' => $this->getStripeConnectSetupComplete(), + $this->mergeWhen(config('app.saas_mode_enabled') && $activeStripePlatform, fn() => [ + 'stripe_account_id' => $activeStripePlatform->getStripeAccountId(), + 'stripe_connect_setup_complete' => $activeStripePlatform->getStripeSetupCompletedAt() !== null, + 'stripe_account_details' => $activeStripePlatform->getStripeAccountDetails(), + 'stripe_platform' => $this->getActiveStripePlatform()?->value, + ]), + $this->mergeWhen($isHiEvents, fn() => [ + 'stripe_hi_events_primary_platform' => config('services.stripe.primary_platform') ]), + $this->mergeWhen($this->getConfiguration() !== null, fn() => [ 'configuration' => new AccountConfigurationResource($this->getConfiguration()), ]), diff --git a/backend/app/Resources/Account/Stripe/StripeConnectAccountsResponseResource.php b/backend/app/Resources/Account/Stripe/StripeConnectAccountsResponseResource.php new file mode 100644 index 0000000000..5a0570a75e --- /dev/null +++ b/backend/app/Resources/Account/Stripe/StripeConnectAccountsResponseResource.php @@ -0,0 +1,36 @@ + [ + 'id' => $this->account->getId(), + 'stripe_platform' => $this->account->getActiveStripePlatform()?->value, + ], + 'stripe_connect_accounts' => $this->stripeConnectAccounts->map(function (StripeConnectAccountDTO $account) { + return [ + 'stripe_account_id' => $account->stripeAccountId, + 'connect_url' => $account->connectUrl, + 'is_setup_complete' => $account->isSetupComplete, + 'platform' => $account->platform?->value, + 'account_type' => $account->accountType, + 'is_primary' => $account->isPrimary, + ]; + })->toArray(), + 'primary_stripe_account_id' => $this->primaryStripeAccountId, + 'has_completed_setup' => $this->hasCompletedSetup, + ]; + } +} diff --git a/backend/app/Resources/Event/EventSettingsResource.php b/backend/app/Resources/Event/EventSettingsResource.php index 84e323d207..4d6fb296ea 100644 --- a/backend/app/Resources/Event/EventSettingsResource.php +++ b/backend/app/Resources/Event/EventSettingsResource.php @@ -47,6 +47,9 @@ public function toArray($request): array 'price_display_mode' => $this->getPriceDisplayMode(), 'hide_getting_started_page' => $this->getHideGettingStartedPage(), + // Ticket design settings + 'ticket_design_settings' => $this->getTicketDesignSettings(), + // Payment settings 'payment_providers' => $this->getPaymentProviders(), 'offline_payment_instructions' => $this->getOfflinePaymentInstructions(), diff --git a/backend/app/Resources/Event/EventSettingsResourcePublic.php b/backend/app/Resources/Event/EventSettingsResourcePublic.php index 11792cd9c0..fa290dc0fd 100644 --- a/backend/app/Resources/Event/EventSettingsResourcePublic.php +++ b/backend/app/Resources/Event/EventSettingsResourcePublic.php @@ -53,6 +53,9 @@ public function toArray($request): array 'location_details' => $this->getLocationDetails(), 'is_online_event' => $this->getIsOnlineEvent(), + // Ticket design settings + 'ticket_design_settings' => $this->getTicketDesignSettings(), + // SEO settings 'seo_title' => $this->getSeoTitle(), 'seo_description' => $this->getSeoDescription(), diff --git a/backend/app/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandler.php b/backend/app/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandler.php index e921350d39..4f1819e25c 100644 --- a/backend/app/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandler.php +++ b/backend/app/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandler.php @@ -3,15 +3,21 @@ namespace HiEvents\Services\Application\Handlers\Account\Payment\Stripe; use HiEvents\DomainObjects\AccountDomainObject; +use HiEvents\DomainObjects\AccountStripePlatformDomainObject; use HiEvents\DomainObjects\Enums\StripeConnectAccountType; -use HiEvents\DomainObjects\Generated\AccountDomainObjectAbstract; +use HiEvents\DomainObjects\Enums\StripePlatform; +use HiEvents\DomainObjects\Generated\AccountStripePlatformDomainObjectAbstract; use HiEvents\Exceptions\CreateStripeConnectAccountFailedException; use HiEvents\Exceptions\CreateStripeConnectAccountLinksFailedException; use HiEvents\Exceptions\SaasModeEnabledException; -use HiEvents\Helper\Url; +use HiEvents\Exceptions\Stripe\StripeClientConfigurationException; use HiEvents\Repository\Interfaces\AccountRepositoryInterface; +use HiEvents\Repository\Interfaces\AccountStripePlatformRepositoryInterface; use HiEvents\Services\Application\Handlers\Account\Payment\Stripe\DTO\CreateStripeConnectAccountDTO; use HiEvents\Services\Application\Handlers\Account\Payment\Stripe\DTO\CreateStripeConnectAccountResponse; +use HiEvents\Services\Domain\Payment\Stripe\StripeAccountSyncService; +use HiEvents\Services\Infrastructure\Stripe\StripeClientFactory; +use HiEvents\Services\Infrastructure\Stripe\StripeConfigurationService; use Illuminate\Config\Repository; use Illuminate\Database\DatabaseManager; use Psr\Log\LoggerInterface; @@ -19,14 +25,17 @@ use Stripe\StripeClient; use Throwable; -readonly class CreateStripeConnectAccountHandler +class CreateStripeConnectAccountHandler { public function __construct( - private AccountRepositoryInterface $accountRepository, - private DatabaseManager $databaseManager, - private StripeClient $stripe, - private LoggerInterface $logger, - private Repository $config, + private readonly AccountRepositoryInterface $accountRepository, + private readonly AccountStripePlatformRepositoryInterface $accountStripePlatformRepository, + private readonly DatabaseManager $databaseManager, + private readonly LoggerInterface $logger, + private readonly Repository $config, + private readonly StripeClientFactory $stripeClientFactory, + private readonly StripeConfigurationService $stripeConfigurationService, + private readonly StripeAccountSyncService $stripeAccountSyncService, ) { } @@ -47,32 +56,61 @@ public function handle(CreateStripeConnectAccountDTO $command): CreateStripeConn /** * @throws CreateStripeConnectAccountFailedException|CreateStripeConnectAccountLinksFailedException + * @throws StripeClientConfigurationException */ private function createOrGetStripeConnectAccount(CreateStripeConnectAccountDTO $command): CreateStripeConnectAccountResponse { - $account = $this->accountRepository->findById($command->accountId); + $account = $this->accountRepository + ->loadRelation(AccountStripePlatformDomainObject::class) + ->findById($command->accountId); + + // If platform is explicitly specified (e.g., for Ireland migration), use it + // Otherwise, use the primary platform from environment (Or null for open-source installations) + if ($command->platform) { + $platformToUse = StripePlatform::fromString($command->platform->value); + } else { + $platformToUse = $this->stripeConfigurationService->getPrimaryPlatform(); + } + + // Try to find existing platform record for the requested platform + // This works for both null (open-source) and specific platforms + $accountStripePlatform = $account->getStripePlatformByType($platformToUse); + + // Open-source installations without platform configuration should still work + // They will use default Stripe keys instead of platform-specific ones + $stripeClient = $this->stripeClientFactory->createForPlatform($platformToUse); $stripeConnectAccount = $this->getOrCreateStripeConnectAccount( account: $account, + accountStripePlatform: $accountStripePlatform, + stripeClient: $stripeClient, + platform: $platformToUse, ); $response = new CreateStripeConnectAccountResponse( stripeConnectAccountType: $stripeConnectAccount->type, stripeAccountId: $stripeConnectAccount->id, account: $account, - isConnectSetupComplete: $this->isStripeAccountComplete($stripeConnectAccount), + isConnectSetupComplete: $this->stripeAccountSyncService->isStripeAccountComplete($stripeConnectAccount), ); if ($response->isConnectSetupComplete) { - // If setup is complete, but this isn't reflected in the account, update it. - if ($account->getStripeConnectSetupComplete() === false) { - $this->updateAccountStripeSetupCompletionStatus($account, $stripeConnectAccount); + // If setup is complete, but this isn't reflected in the account stripe platform, update it. + if ($accountStripePlatform && $accountStripePlatform->getStripeSetupCompletedAt() === null) { + $this->stripeAccountSyncService->markAccountAsComplete($accountStripePlatform, $stripeConnectAccount); } return $response; } - $response->connectUrl = $this->getStripeAccountSetupUrl($stripeConnectAccount, $account); + $connectUrl = $this->stripeAccountSyncService->createStripeAccountSetupUrl($stripeConnectAccount, $stripeClient); + if ($connectUrl === null) { + throw new CreateStripeConnectAccountLinksFailedException( + message: __('There are issues with creating the Stripe Connect Account Link. Please try again.'), + ); + } + + $response->connectUrl = $connectUrl; return $response; } @@ -80,22 +118,28 @@ private function createOrGetStripeConnectAccount(CreateStripeConnectAccountDTO $ /** * @throws CreateStripeConnectAccountFailedException */ - private function getOrCreateStripeConnectAccount(AccountDomainObject $account): Account + private function getOrCreateStripeConnectAccount( + AccountDomainObject $account, + ?AccountStripePlatformDomainObject $accountStripePlatform, + StripeClient $stripeClient, + ?StripePlatform $platform + ): Account { try { - if ($account->getStripeAccountId() !== null) { - return $this->stripe->accounts->retrieve($account->getStripeAccountId()); + if ($accountStripePlatform && $accountStripePlatform->getStripeAccountId() !== null) { + return $stripeClient->accounts->retrieve($accountStripePlatform->getStripeAccountId()); } - $stripeAccount = $this->stripe->accounts->create([ + $stripeAccount = $stripeClient->accounts->create([ 'type' => $this->config->get('app.stripe_connect_account_type') ?? StripeConnectAccountType::EXPRESS->value, ]); } catch (Throwable $e) { $this->logger->error('Failed to create or fetch Stripe Connect Account: ' . $e->getMessage(), [ 'accountId' => $account->getId(), - 'stripeAccountId' => $account->getStripeAccountId() ?? 'null', - 'accountExists' => $account->getStripeAccountId() !== null ? 'true' : 'false', + 'stripeAccountId' => $accountStripePlatform?->getStripeAccountId() ?? 'null', + 'accountExists' => $accountStripePlatform?->getStripeAccountId() !== null ? 'true' : 'false', + 'platform' => $platform?->value ?? 'null', 'exception' => $e, ]); @@ -105,80 +149,28 @@ private function getOrCreateStripeConnectAccount(AccountDomainObject $account): ); } - $this->accountRepository->updateWhere( - attributes: [ - AccountDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id, - AccountDomainObjectAbstract::STRIPE_CONNECT_ACCOUNT_TYPE => $stripeAccount->type, - ], - where: [ - 'id' => $account->getId(), - ] - ); - - return $stripeAccount; - } - - /** - * @param Account $stripAccount - * @return bool - */ - private function isStripeAccountComplete(Account $stripAccount): bool - { - return $stripAccount->charges_enabled - && $stripAccount->payouts_enabled; - } - - /** - * @throws CreateStripeConnectAccountLinksFailedException - */ - private function getStripeAccountSetupUrl(Account $stripAccount, AccountDomainObject $account): string - { - try { - $accountLink = $this->stripe->accountLinks->create([ - 'account' => $stripAccount->id, - 'refresh_url' => Url::getFrontEndUrlFromConfig(Url::STRIPE_CONNECT_REFRESH_URL, [ - 'is_refresh' => true, - ]), - 'return_url' => Url::getFrontEndUrlFromConfig(Url::STRIPE_CONNECT_RETURN_URL, [ - 'is_return' => true, - ]), - 'type' => 'account_onboarding', - ]); - - } catch (Throwable $e) { - $this->logger->error('Failed to create Stripe Connect Account Link: ' . $e->getMessage(), [ - 'accountId' => $account->getId(), - 'stripeAccountId' => $stripAccount->id, - 'exception' => $e, + // Create or update account stripe platform record + if (!$accountStripePlatform) { + $this->accountStripePlatformRepository->create([ + AccountStripePlatformDomainObjectAbstract::ACCOUNT_ID => $account->getId(), + AccountStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id, + AccountStripePlatformDomainObjectAbstract::STRIPE_CONNECT_ACCOUNT_TYPE => $stripeAccount->type, + AccountStripePlatformDomainObjectAbstract::STRIPE_CONNECT_PLATFORM => $platform?->value, ]); - - throw new CreateStripeConnectAccountLinksFailedException( - message: __('There are issues with creating the Stripe Connect Account Link. Please try again.'), - previous: $e, + } else { + $this->accountStripePlatformRepository->updateWhere( + attributes: [ + AccountStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id, + AccountStripePlatformDomainObjectAbstract::STRIPE_CONNECT_ACCOUNT_TYPE => $stripeAccount->type, + ], + where: [ + 'id' => $accountStripePlatform->getId(), + ] ); } - return $accountLink->url; + return $stripeAccount; } - private function updateAccountStripeSetupCompletionStatus( - AccountDomainObject $account, - Account $stripeConnectAccount, - ): void - { - $this->accountRepository->updateWhere( - attributes: [ - AccountDomainObjectAbstract::STRIPE_CONNECT_SETUP_COMPLETE => true, - ], - where: [ - 'id' => $account->getId(), - ] - ); - $this->logger->info(sprintf( - 'Stripe Connect account setup completed for account %s with Stripe account ID %s', - $account->getId(), - $stripeConnectAccount->id - )); - } } diff --git a/backend/app/Services/Application/Handlers/Account/Payment/Stripe/DTO/CreateStripeConnectAccountDTO.php b/backend/app/Services/Application/Handlers/Account/Payment/Stripe/DTO/CreateStripeConnectAccountDTO.php index 38a4ff3a8c..c6693f60a5 100644 --- a/backend/app/Services/Application/Handlers/Account/Payment/Stripe/DTO/CreateStripeConnectAccountDTO.php +++ b/backend/app/Services/Application/Handlers/Account/Payment/Stripe/DTO/CreateStripeConnectAccountDTO.php @@ -2,12 +2,14 @@ namespace HiEvents\Services\Application\Handlers\Account\Payment\Stripe\DTO; -use HiEvents\DataTransferObjects\BaseDTO; +use HiEvents\DataTransferObjects\BaseDataObject; +use HiEvents\DomainObjects\Enums\StripePlatform; -class CreateStripeConnectAccountDTO extends BaseDTO +class CreateStripeConnectAccountDTO extends BaseDataObject { public function __construct( - public readonly int $accountId, + public readonly int $accountId, + public readonly StripePlatform|null $platform = null, ) { } diff --git a/backend/app/Services/Application/Handlers/Account/Payment/Stripe/DTO/GetStripeConnectAccountsResponseDTO.php b/backend/app/Services/Application/Handlers/Account/Payment/Stripe/DTO/GetStripeConnectAccountsResponseDTO.php new file mode 100644 index 0000000000..6c1c79d019 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Account/Payment/Stripe/DTO/GetStripeConnectAccountsResponseDTO.php @@ -0,0 +1,18 @@ +accountRepository + ->loadRelation(AccountStripePlatformDomainObject::class) + ->findById($accountId); + + $stripeConnectAccounts = $this->getStripeConnectAccounts($account); + $primaryStripeAccountId = $account->getActiveStripeAccountId(); + $hasCompletedSetup = $account->isStripeSetupComplete(); + + return new GetStripeConnectAccountsResponseDTO( + account: $account, + stripeConnectAccounts: $stripeConnectAccounts, + primaryStripeAccountId: $primaryStripeAccountId, + hasCompletedSetup: $hasCompletedSetup, + ); + } + + private function getStripeConnectAccounts(AccountDomainObject $account): Collection + { + $stripeAccounts = collect(); + $stripePlatforms = $account->getAccountStripePlatforms(); + + if (!$stripePlatforms || $stripePlatforms->isEmpty()) { + return $stripeAccounts; + } + + foreach ($stripePlatforms as $stripePlatform) { + $stripeAccount = $this->getStripeAccount($stripePlatform); + if ($stripeAccount) { + $stripeAccounts->push($stripeAccount); + } + } + + return $stripeAccounts; + } + + private function getStripeAccount(AccountStripePlatformDomainObject $stripePlatform): ?StripeConnectAccountDTO + { + if (!$stripePlatform->getStripeAccountId()) { + return null; + } + + try { + $platform = $stripePlatform->getStripeConnectPlatform() + ? StripePlatform::fromString($stripePlatform->getStripeConnectPlatform()) + : null; + + $stripeClient = $this->stripeClientFactory->createForPlatform($platform); + $stripeAccount = $stripeClient->accounts->retrieve($stripePlatform->getStripeAccountId()); + + $isSetupComplete = $this->stripeAccountSyncService->isStripeAccountComplete($stripeAccount); + $connectUrl = null; + + // Check if Stripe says setup is complete but our DB doesn't reflect it + if ($isSetupComplete && $stripePlatform->getStripeSetupCompletedAt() === null) { + $this->stripeAccountSyncService->markAccountAsComplete($stripePlatform, $stripeAccount); + } + + // Generate connect URL if setup is not complete + if (!$isSetupComplete) { + $connectUrl = $this->stripeAccountSyncService->createStripeAccountSetupUrl($stripeAccount, $stripeClient); + } + + return new StripeConnectAccountDTO( + stripeAccountId: $stripeAccount->id, + connectUrl: $connectUrl, + isSetupComplete: $isSetupComplete, + platform: $platform, + accountType: $stripeAccount->type, + isPrimary: $stripePlatform->getStripeSetupCompletedAt() !== null, + ); + } catch (StripeClientConfigurationException $e) { + $this->logger->warning('Failed to retrieve Stripe account due to configuration issue', [ + 'stripe_account_id' => $stripePlatform->getStripeAccountId(), + 'platform' => $stripePlatform->getStripeConnectPlatform(), + 'error' => $e->getMessage(), + ]); + return null; + } catch (Throwable $e) { + $this->logger->error('Failed to retrieve Stripe account', [ + 'stripe_account_id' => $stripePlatform->getStripeAccountId(), + 'platform' => $stripePlatform->getStripeConnectPlatform(), + 'error' => $e->getMessage(), + ]); + return null; + } + } +} diff --git a/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php b/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php index 9a714d2a80..8a7051bfd9 100644 --- a/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php +++ b/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php @@ -39,10 +39,10 @@ public function __construct( public function handle(EditAttendeeDTO $editAttendeeDTO): AttendeeDomainObject { return $this->databaseManager->transaction(function () use ($editAttendeeDTO) { - $this->validateProductId($editAttendeeDTO); - $attendee = $this->getAttendee($editAttendeeDTO); + $this->validateProductId($editAttendeeDTO, $attendee); + $this->adjustProductQuantities($attendee, $editAttendeeDTO); $updatedAttendee = $this->updateAttendee($editAttendeeDTO); @@ -84,7 +84,10 @@ private function updateAttendee(EditAttendeeDTO $editAttendeeDTO): AttendeeDomai * @throws ValidationException * @throws NoTicketsAvailableException */ - private function validateProductId(EditAttendeeDTO $editAttendeeDTO): void + private function validateProductId( + EditAttendeeDTO $editAttendeeDTO, + AttendeeDomainObject $attendee, + ): void { /** @var ProductDomainObject $product */ $product = $this->productRepository @@ -106,6 +109,11 @@ private function validateProductId(EditAttendeeDTO $editAttendeeDTO): void ]); } + // No need to check availability if the product price hasn't changed + if ($attendee->getProductPriceId() === $editAttendeeDTO->product_price_id) { + return; + } + $availableQuantity = $this->productRepository->getQuantityRemainingForProductPrice( productId: $editAttendeeDTO->product_id, productPriceId: $product->getType() === ProductPriceType::TIERED->name diff --git a/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php b/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php index e724b88d3e..0256ccd6bc 100644 --- a/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php +++ b/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php @@ -5,7 +5,9 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\Status\AttendeeStatus; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; +use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Services\Application\Handlers\Attendee\DTO\PartialEditAttendeeDTO; +use HiEvents\Services\Domain\EventStatistics\EventStatisticsCancellationService; use HiEvents\Services\Domain\Product\ProductQuantityUpdateService; use HiEvents\Services\Infrastructure\DomainEvents\DomainEventDispatcherService; use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; @@ -17,10 +19,12 @@ class PartialEditAttendeeHandler { public function __construct( - private readonly AttendeeRepositoryInterface $attendeeRepository, - private readonly ProductQuantityUpdateService $productQuantityService, - private readonly DatabaseManager $databaseManager, - private readonly DomainEventDispatcherService $domainEventDispatcherService, + private readonly AttendeeRepositoryInterface $attendeeRepository, + private readonly OrderRepositoryInterface $orderRepository, + private readonly ProductQuantityUpdateService $productQuantityService, + private readonly DatabaseManager $databaseManager, + private readonly DomainEventDispatcherService $domainEventDispatcherService, + private readonly EventStatisticsCancellationService $eventStatisticsCancellationService, ) { } @@ -50,6 +54,7 @@ private function updateAttendee(PartialEditAttendeeDTO $data): AttendeeDomainObj if ($statusIsUpdated) { $this->adjustProductQuantity($data, $attendee); + $this->adjustEventStatistics($data, $attendee); } if ($statusIsUpdated && $data->status === AttendeeStatus::CANCELLED->name) { @@ -87,4 +92,22 @@ private function adjustProductQuantity(PartialEditAttendeeDTO $data, AttendeeDom $this->productQuantityService->decreaseQuantitySold($attendee->getProductPriceId()); } } + + /** + * Adjust event statistics when attendee status changes + * + * @throws Throwable + */ + private function adjustEventStatistics(PartialEditAttendeeDTO $data, AttendeeDomainObject $attendee): void + { + if ($data->status === AttendeeStatus::CANCELLED->name) { + // Get the order to access the creation date for daily statistics + $order = $this->orderRepository->findById($attendee->getOrderId()); + + $this->eventStatisticsCancellationService->decrementForCancelledAttendee( + eventId: $attendee->getEventId(), + orderDate: $order->getCreatedAt() + ); + } + } } diff --git a/backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php b/backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php index 7ef12805ef..e8380b1766 100644 --- a/backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php +++ b/backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\OrderDomainObject; +use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\Status\AttendeeStatus; use HiEvents\Exceptions\ResourceConflictException; @@ -32,7 +33,9 @@ public function __construct( public function handle(ResendAttendeeTicketDTO $resendAttendeeProductDTO): void { $attendee = $this->attendeeRepository - ->loadRelation(new Relationship(OrderDomainObject::class, name: 'order')) + ->loadRelation(new Relationship(OrderDomainObject::class, nested: [ + new Relationship(OrderItemDomainObject::class), + ], name: 'order')) ->findFirstWhere([ 'id' => $resendAttendeeProductDTO->attendeeId, 'event_id' => $resendAttendeeProductDTO->eventId, diff --git a/backend/app/Services/Application/Handlers/EmailTemplate/CreateEmailTemplateHandler.php b/backend/app/Services/Application/Handlers/EmailTemplate/CreateEmailTemplateHandler.php new file mode 100644 index 0000000000..afc93bb3b1 --- /dev/null +++ b/backend/app/Services/Application/Handlers/EmailTemplate/CreateEmailTemplateHandler.php @@ -0,0 +1,59 @@ +emailTemplateService->validateTemplate($dto->subject, $dto->body); + if (!$validation['valid']) { + $exception = new EmailTemplateValidationException('Template validation failed'); + $exception->validationErrors = $validation['errors']; + throw $exception; + } + + // Check for existing template + $existing = $this->emailTemplateRepository->findByTypeAndScope( + $dto->template_type, + $dto->account_id, + $dto->event_id, + $dto->organizer_id + ); + + if ($existing) { + throw new ResourceConflictException('A template already exists for this type and scope'); + } + + // Create the template + return $this->emailTemplateRepository->create([ + 'account_id' => $dto->account_id, + 'organizer_id' => $dto->organizer_id, + 'event_id' => $dto->event_id, + 'template_type' => $dto->template_type->value, + 'subject' => $dto->subject, + 'body' => $dto->body, + 'cta' => $dto->cta, + 'engine' => $dto->engine->value, + 'is_active' => $dto->is_active, + ]); + } +} diff --git a/backend/app/Services/Application/Handlers/EmailTemplate/DTO/DeleteEmailTemplateDTO.php b/backend/app/Services/Application/Handlers/EmailTemplate/DTO/DeleteEmailTemplateDTO.php new file mode 100644 index 0000000000..cad0838d8b --- /dev/null +++ b/backend/app/Services/Application/Handlers/EmailTemplate/DTO/DeleteEmailTemplateDTO.php @@ -0,0 +1,15 @@ +emailTemplateRepository->findFirstWhere([ + 'id' => $dto->id, + 'account_id' => $dto->account_id, + ]); + + if (!$template) { + throw new EmailTemplateNotFoundException(__('Email template not found')); + } + + return $this->emailTemplateRepository->deleteById($template->getId()); + } +} diff --git a/backend/app/Services/Application/Handlers/EmailTemplate/GetAvailableTokensHandler.php b/backend/app/Services/Application/Handlers/EmailTemplate/GetAvailableTokensHandler.php new file mode 100644 index 0000000000..72d16388b6 --- /dev/null +++ b/backend/app/Services/Application/Handlers/EmailTemplate/GetAvailableTokensHandler.php @@ -0,0 +1,19 @@ +liquidRenderer->getAvailableTokens($templateType); + } +} \ No newline at end of file diff --git a/backend/app/Services/Application/Handlers/EmailTemplate/GetEmailTemplatesHandler.php b/backend/app/Services/Application/Handlers/EmailTemplate/GetEmailTemplatesHandler.php new file mode 100644 index 0000000000..585ba6a2c7 --- /dev/null +++ b/backend/app/Services/Application/Handlers/EmailTemplate/GetEmailTemplatesHandler.php @@ -0,0 +1,40 @@ + $dto->account_id, + ]; + + if ($dto->event_id) { + $conditions['event_id'] = $dto->event_id; + } + + if ($dto->organizer_id) { + $conditions['organizer_id'] = $dto->organizer_id; + } + + if ($dto->template_type) { + $conditions['template_type'] = $dto->template_type->value; + } + + if (!$dto->include_inactive) { + $conditions['is_active'] = true; + } + + return $this->emailTemplateRepository->findWhere($conditions); + } +} \ No newline at end of file diff --git a/backend/app/Services/Application/Handlers/EmailTemplate/PreviewEmailTemplateHandler.php b/backend/app/Services/Application/Handlers/EmailTemplate/PreviewEmailTemplateHandler.php new file mode 100644 index 0000000000..e55357ee05 --- /dev/null +++ b/backend/app/Services/Application/Handlers/EmailTemplate/PreviewEmailTemplateHandler.php @@ -0,0 +1,24 @@ +emailTemplateService->previewTemplate( + $dto->subject, + $dto->body, + $dto->template_type, + $dto->cta + ); + } +} \ No newline at end of file diff --git a/backend/app/Services/Application/Handlers/EmailTemplate/UpdateEmailTemplateHandler.php b/backend/app/Services/Application/Handlers/EmailTemplate/UpdateEmailTemplateHandler.php new file mode 100644 index 0000000000..b26f25b1a9 --- /dev/null +++ b/backend/app/Services/Application/Handlers/EmailTemplate/UpdateEmailTemplateHandler.php @@ -0,0 +1,56 @@ +id) { + throw new InvalidEmailTemplateException('Template ID is required for update'); + } + + $validation = $this->emailTemplateService->validateTemplate($dto->subject, $dto->body); + if (!$validation['valid']) { + $exception = new EmailTemplateValidationException('Template validation failed'); + $exception->validationErrors = $validation['errors']; + throw $exception; + } + + $template = $this->emailTemplateRepository->findFirstWhere([ + 'id' => $dto->id, + 'account_id' => $dto->account_id, + ]); + + if (!$template) { + throw new EmailTemplateNotFoundException('Email template not found'); + } + + return $this->emailTemplateRepository->updateFromArray($template->getId(), [ + 'subject' => $dto->subject, + 'body' => $dto->body, + 'cta' => $dto->cta, + 'engine' => $dto->engine->value, + 'is_active' => $dto->is_active, + ]); + } +} diff --git a/backend/app/Services/Application/Handlers/Event/DuplicateEventHandler.php b/backend/app/Services/Application/Handlers/Event/DuplicateEventHandler.php index ec841f3f45..11dd929687 100644 --- a/backend/app/Services/Application/Handlers/Event/DuplicateEventHandler.php +++ b/backend/app/Services/Application/Handlers/Event/DuplicateEventHandler.php @@ -32,6 +32,7 @@ public function handle(DuplicateEventDataDTO $data): EventDomainObject duplicateCapacityAssignments: $data->duplicateCapacityAssignments, duplicateCheckInLists: $data->duplicateCheckInLists, duplicateEventCoverImage: $data->duplicateEventCoverImage, + duplicateTicketLogo: $data->duplicateTicketLogo, duplicateWebhooks: $data->duplicateWebhooks, duplicateAffiliates: $data->duplicateAffiliates, description: $data->description, diff --git a/backend/app/Services/Application/Handlers/Event/GetPublicEventsHandler.php b/backend/app/Services/Application/Handlers/Event/GetPublicEventsHandler.php index a83fe3654b..933f1fc1c0 100644 --- a/backend/app/Services/Application/Handlers/Event/GetPublicEventsHandler.php +++ b/backend/app/Services/Application/Handlers/Event/GetPublicEventsHandler.php @@ -12,19 +12,23 @@ use HiEvents\Repository\Eloquent\Value\OrderAndDirection; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\EventRepositoryInterface; +use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; use HiEvents\Services\Application\Handlers\Event\DTO\GetPublicOrganizerEventsDTO; use Illuminate\Pagination\LengthAwarePaginator; class GetPublicEventsHandler { public function __construct( - private readonly EventRepositoryInterface $eventRepository, + private readonly EventRepositoryInterface $eventRepository, + private readonly OrganizerRepositoryInterface $organizerRepository, ) { } public function handle(GetPublicOrganizerEventsDTO $dto): LengthAwarePaginator { + $organizer = $this->organizerRepository->findById($dto->organizerId); + $query = $this->eventRepository ->loadRelation( new Relationship(ProductCategoryDomainObject::class, [ @@ -42,7 +46,8 @@ public function handle(GetPublicOrganizerEventsDTO $dto): LengthAwarePaginator ->loadRelation(new Relationship(EventSettingDomainObject::class)) ->loadRelation(new Relationship(ImageDomainObject::class)); - if ($dto->authenticatedAccountId) { + // If the organizer is viewing their own profile, we show all events, even those in draft + if ($dto->authenticatedAccountId && $organizer->getAccountId() === $dto->authenticatedAccountId) { return $query->findEventsForOrganizer( organizerId: $dto->organizerId, accountId: $dto->authenticatedAccountId, diff --git a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php index fb7721096f..cd9ab7bc8b 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php +++ b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php @@ -66,6 +66,9 @@ public function __construct( public readonly ?string $invoice_tax_details = null, public readonly ?string $invoice_notes = null, public readonly ?int $invoice_payment_terms_days = null, + + // Ticket design settings + public readonly ?array $ticket_design_settings = null, ) { } @@ -121,6 +124,15 @@ public static function createWithDefaults( invoice_tax_details: null, invoice_notes: null, invoice_payment_terms_days: null, + + // Ticket design defaults + ticket_design_settings: [ + 'accent_color' => '#333333', + 'logo_image_id' => null, + 'footer_text' => null, + 'layout_type' => 'classic', + 'enabled' => true, + ], ); } } diff --git a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php index 817d82776d..67bb45602d 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php @@ -10,11 +10,11 @@ use HiEvents\Services\Application\Handlers\EventSettings\DTO\UpdateEventSettingsDTO; use Throwable; -readonly class PartialUpdateEventSettingsHandler +class PartialUpdateEventSettingsHandler { public function __construct( - private UpdateEventSettingsHandler $eventSettingsHandler, - private EventSettingsRepositoryInterface $eventSettingsRepository, + private readonly UpdateEventSettingsHandler $eventSettingsHandler, + private readonly EventSettingsRepositoryInterface $eventSettingsRepository, ) { } @@ -116,7 +116,12 @@ public function handle(PartialUpdateEventSettingsDTO $eventSettingsDTO): EventSe : $existingSettings->getInvoiceNotes(), 'invoice_payment_terms_days' => array_key_exists('invoice_payment_terms_days', $eventSettingsDTO->settings) ? $eventSettingsDTO->settings['invoice_payment_terms_days'] - : $existingSettings->getInvoicePaymentTermsDays() + : $existingSettings->getInvoicePaymentTermsDays(), + + // Ticket design settings + 'ticket_design_settings' => array_key_exists('ticket_design_settings', $eventSettingsDTO->settings) + ? $eventSettingsDTO->settings['ticket_design_settings'] + : $existingSettings->getTicketDesignSettings() ]), ); } diff --git a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php index f999b8312f..443f135ba7 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php @@ -9,12 +9,12 @@ use Illuminate\Database\DatabaseManager; use Throwable; -readonly class UpdateEventSettingsHandler +class UpdateEventSettingsHandler { public function __construct( - private EventSettingsRepositoryInterface $eventSettingsRepository, - private HtmlPurifierService $purifier, - private DatabaseManager $databaseManager, + private readonly EventSettingsRepositoryInterface $eventSettingsRepository, + private readonly HtmlPurifierService $purifier, + private readonly DatabaseManager $databaseManager, ) { } @@ -78,6 +78,9 @@ public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObje '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, + + // Ticket design settings + 'ticket_design_settings' => $settings->ticket_design_settings, ], where: [ 'event_id' => $settings->event_id, diff --git a/backend/app/Services/Application/Handlers/Images/CreateImageHandler.php b/backend/app/Services/Application/Handlers/Images/CreateImageHandler.php index 37a1b85ca0..ff74876a6a 100644 --- a/backend/app/Services/Application/Handlers/Images/CreateImageHandler.php +++ b/backend/app/Services/Application/Handlers/Images/CreateImageHandler.php @@ -20,6 +20,7 @@ class CreateImageHandler ImageType::ORGANIZER_LOGO, ImageType::ORGANIZER_COVER, ImageType::EVENT_COVER, + ImageType::TICKET_LOGO, ]; public function __construct( diff --git a/backend/app/Services/Application/Handlers/Order/CancelOrderHandler.php b/backend/app/Services/Application/Handlers/Order/CancelOrderHandler.php index c44af907eb..e1e4ed099d 100644 --- a/backend/app/Services/Application/Handlers/Order/CancelOrderHandler.php +++ b/backend/app/Services/Application/Handlers/Order/CancelOrderHandler.php @@ -7,6 +7,8 @@ use HiEvents\Exceptions\ResourceConflictException; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Services\Application\Handlers\Order\DTO\CancelOrderDTO; +use HiEvents\Services\Application\Handlers\Order\DTO\RefundOrderDTO; +use HiEvents\Services\Application\Handlers\Order\Payment\Stripe\RefundOrderHandler; use HiEvents\Services\Domain\Order\OrderCancelService; use Illuminate\Database\DatabaseManager; use Symfony\Component\Routing\Exception\ResourceNotFoundException; @@ -18,6 +20,7 @@ public function __construct( private readonly OrderCancelService $orderCancelService, private readonly OrderRepositoryInterface $orderRepository, private readonly DatabaseManager $databaseManager, + private readonly RefundOrderHandler $refundOrderHandler, ) { } @@ -45,6 +48,18 @@ public function handle(CancelOrderDTO $cancelOrderDTO): OrderDomainObject $this->orderCancelService->cancelOrder($order); + if ($cancelOrderDTO->refund && $order->isRefundable()) { + $refundDTO = new RefundOrderDTO( + event_id: $cancelOrderDTO->eventId, + order_id: $cancelOrderDTO->orderId, + amount: $order->getTotalGross() - $order->getTotalRefunded(), + notify_buyer: true, + cancel_order: false, + ); + + $this->refundOrderHandler->handle($refundDTO); + } + return $this->orderRepository->findById($order->getId()); }); } diff --git a/backend/app/Services/Application/Handlers/Order/DTO/CancelOrderDTO.php b/backend/app/Services/Application/Handlers/Order/DTO/CancelOrderDTO.php index 7f6069fbbc..e8941d2516 100644 --- a/backend/app/Services/Application/Handlers/Order/DTO/CancelOrderDTO.php +++ b/backend/app/Services/Application/Handlers/Order/DTO/CancelOrderDTO.php @@ -8,7 +8,8 @@ class CancelOrderDTO extends BaseDTO { public function __construct( public int $eventId, - public int $orderId + public int $orderId, + public bool $refund = false ) { } diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php index 0db40c5914..efc6145041 100644 --- a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php +++ b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php @@ -7,6 +7,7 @@ use Brick\Math\Exception\RoundingNecessaryException; use Brick\Money\Exception\UnknownCurrencyException; use HiEvents\DomainObjects\AccountConfigurationDomainObject; +use HiEvents\DomainObjects\AccountStripePlatformDomainObject; use HiEvents\DomainObjects\Generated\StripePaymentDomainObjectAbstract; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\Status\OrderStatus; @@ -18,6 +19,8 @@ use HiEvents\Repository\Interfaces\AccountRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\StripePaymentsRepositoryInterface; +use HiEvents\Services\Infrastructure\Stripe\StripeClientFactory; +use HiEvents\Services\Infrastructure\Stripe\StripeConfigurationService; use HiEvents\Services\Domain\Payment\Stripe\DTOs\CreatePaymentIntentRequestDTO; use HiEvents\Services\Domain\Payment\Stripe\DTOs\CreatePaymentIntentResponseDTO; use HiEvents\Services\Domain\Payment\Stripe\StripePaymentIntentCreationService; @@ -34,6 +37,8 @@ public function __construct( private CheckoutSessionManagementService $sessionIdentifierService, private StripePaymentsRepositoryInterface $stripePaymentsRepository, private AccountRepositoryInterface $accountRepository, + private StripeClientFactory $stripeClientFactory, + private StripeConfigurationService $stripeConfigurationService, ) { } @@ -69,34 +74,63 @@ public function handle(string $orderShortId): CreatePaymentIntentResponseDTO domainObject: AccountConfigurationDomainObject::class, name: 'configuration', )) + ->loadRelation(AccountStripePlatformDomainObject::class) ->findByEventId($order->getEventId()); + $stripePlatform = $account->getActiveStripePlatform() + ?? $this->stripeConfigurationService->getPrimaryPlatform(); + + $stripeAccountId = $account->getActiveStripeAccountId(); + + // If no platform is configured, we can still process payments with regular Stripe keys + if (!$stripePlatform) { + $stripePlatform = null; // This will use default keys in StripeClientFactory + } + + $stripeClient = $this->stripeClientFactory->createForPlatform($stripePlatform); + $publicKey = $this->stripeConfigurationService->getPublicKey($stripePlatform); + // If we already have a Stripe session then re-fetch the client secret if ($order->getStripePayment() !== null) { return new CreatePaymentIntentResponseDTO( paymentIntentId: $order->getStripePayment()->getPaymentIntentId(), - clientSecret: $this->stripePaymentService->retrievePaymentIntentClientSecret( + clientSecret: $this->stripePaymentService->retrievePaymentIntentClientSecretWithClient( + $stripeClient, $order->getStripePayment()->getPaymentIntentId(), - $account->getStripeAccountId() + $stripeAccountId ), - accountId: $account->getStripeAccountId(), + accountId: $stripeAccountId, + stripePlatform: $stripePlatform, + publicKey: $publicKey, ); } - $paymentIntent = $this->stripePaymentService->createPaymentIntent(CreatePaymentIntentRequestDTO::fromArray([ - 'amount' => MoneyValue::fromFloat($order->getTotalGross(), $order->getCurrency()), - 'currencyCode' => $order->getCurrency(), - 'account' => $account, - 'order' => $order, - ])); + $paymentIntent = $this->stripePaymentService->createPaymentIntentWithClient( + $stripeClient, + CreatePaymentIntentRequestDTO::fromArray([ + 'amount' => MoneyValue::fromFloat($order->getTotalGross(), $order->getCurrency()), + 'currencyCode' => $order->getCurrency(), + 'account' => $account, + 'order' => $order, + 'stripeAccountId' => $stripeAccountId, + ]) + ); $this->stripePaymentsRepository->create([ StripePaymentDomainObjectAbstract::ORDER_ID => $order->getId(), StripePaymentDomainObjectAbstract::PAYMENT_INTENT_ID => $paymentIntent->paymentIntentId, - StripePaymentDomainObjectAbstract::CONNECTED_ACCOUNT_ID => $account->getStripeAccountId(), + StripePaymentDomainObjectAbstract::CONNECTED_ACCOUNT_ID => $stripeAccountId, StripePaymentDomainObjectAbstract::APPLICATION_FEE => $paymentIntent->applicationFeeAmount, + StripePaymentDomainObjectAbstract::STRIPE_PLATFORM => $stripePlatform?->value, ]); - return $paymentIntent; + return new CreatePaymentIntentResponseDTO( + paymentIntentId: $paymentIntent->paymentIntentId, + clientSecret: $paymentIntent->clientSecret, + accountId: $paymentIntent->accountId, + applicationFeeAmount: $paymentIntent->applicationFeeAmount, + stripePlatform: $stripePlatform, + publicKey: $publicKey, + ); } } diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/GetPaymentIntentHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/GetPaymentIntentHandler.php index 415027fb9a..b129c1425b 100644 --- a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/GetPaymentIntentHandler.php +++ b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/GetPaymentIntentHandler.php @@ -2,21 +2,22 @@ namespace HiEvents\Services\Application\Handlers\Order\Payment\Stripe; +use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\Status\OrderPaymentStatus; use HiEvents\DomainObjects\StripePaymentDomainObject; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Services\Application\Handlers\Order\Payment\Stripe\DTO\StripePaymentIntentPublicDTO; use HiEvents\Services\Domain\Payment\Stripe\EventHandlers\PaymentIntentSucceededHandler; +use HiEvents\Services\Infrastructure\Stripe\StripeClientFactory; use Psr\Log\LoggerInterface; use Stripe\Exception\ApiErrorException; -use Stripe\StripeClient; use Symfony\Component\Routing\Exception\ResourceNotFoundException; class GetPaymentIntentHandler { public function __construct( - private readonly StripeClient $stripeClient, + private readonly StripeClientFactory $stripeClientFactory, private readonly OrderRepositoryInterface $orderRepository, private readonly LoggerInterface $logger, private readonly PaymentIntentSucceededHandler $paymentIntentSucceededHandler, @@ -26,6 +27,7 @@ public function __construct( public function handle(int $eventId, string $orderShortId): StripePaymentIntentPublicDTO { + /** @var OrderDomainObject $order */ $order = $this->orderRepository ->loadRelation(new Relationship( domainObject: StripePaymentDomainObject::class, @@ -37,9 +39,11 @@ public function handle(int $eventId, string $orderShortId): StripePaymentIntentP ]); $accountId = $order->getStripePayment()->getConnectedAccountId(); + $paymentPlatform = $order->getStripePayment()->getStripePlatformEnum(); try { - $paymentIntent = $this->stripeClient->paymentIntents->retrieve( + $stripeClient = $this->stripeClientFactory->createForPlatform($paymentPlatform); + $paymentIntent = $stripeClient->paymentIntents->retrieve( id: $order->getStripePayment()->getPaymentIntentId(), opts: $accountId ? ['stripe_account' => $accountId] : [] ); diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/IncomingWebhookHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/IncomingWebhookHandler.php index c56330ca50..4eef8a6e05 100644 --- a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/IncomingWebhookHandler.php +++ b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/IncomingWebhookHandler.php @@ -16,14 +16,15 @@ use Stripe\Webhook; use Throwable; use UnexpectedValueException; +use HiEvents\Services\Infrastructure\Stripe\StripeConfigurationService; class IncomingWebhookHandler { private static array $validEvents = [ Event::PAYMENT_INTENT_SUCCEEDED, Event::PAYMENT_INTENT_PAYMENT_FAILED, - Event::CHARGE_REFUND_UPDATED, Event::ACCOUNT_UPDATED, + Event::REFUND_UPDATED, ]; public function __construct( @@ -33,6 +34,7 @@ public function __construct( private readonly AccountUpdateHandler $accountUpdateHandler, private readonly Logger $logger, private readonly Repository $cache, + private readonly StripeConfigurationService $stripeConfigurationService, ) { } @@ -45,11 +47,7 @@ public function __construct( public function handle(StripeWebhookDTO $webhookDTO): void { try { - $event = Webhook::constructEvent( - $webhookDTO->payload, - $webhookDTO->headerSignature, - config('services.stripe.webhook_secret'), - ); + $event = $this->constructEventWithValidPlatform($webhookDTO); if (!in_array($event->type, self::$validEvents, true)) { $this->logger->debug(__('Received a :event Stripe event, which has no handler', [ @@ -81,7 +79,7 @@ public function handle(StripeWebhookDTO $webhookDTO): void case Event::PAYMENT_INTENT_PAYMENT_FAILED: $this->paymentIntentFailedHandler->handleEvent($event->data->object); break; - case Event::CHARGE_REFUND_UPDATED: + case Event::REFUND_UPDATED: $this->refundEventHandlerService->handleEvent($event->data->object); break; case Event::ACCOUNT_UPDATED: @@ -119,6 +117,38 @@ public function handle(StripeWebhookDTO $webhookDTO): void } } + private function constructEventWithValidPlatform(StripeWebhookDTO $webhookDTO): Event + { + $webhookSecrets = $this->stripeConfigurationService->getAllWebhookSecrets(); + $lastException = null; + + foreach ($webhookSecrets as $platform => $webhookSecret) { + try { + if (!$webhookSecret) { + continue; + } + + $event = Webhook::constructEvent( + $webhookDTO->payload, + $webhookDTO->headerSignature, + $webhookSecret + ); + + $this->logger->debug('Webhook validated with platform: ' . $platform, [ + 'event_id' => $event->id, + 'platform' => $platform, + ]); + + return $event; + } catch (SignatureVerificationException $exception) { + $lastException = $exception; + continue; + } + } + + throw $lastException ?? new SignatureVerificationException(__('Unable to verify Stripe signature with any platform')); + } + private function hasEventBeenHandled(Event $event): bool { return $this->cache->has('stripe_event_' . $event->id); diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/RefundOrderHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/RefundOrderHandler.php index 3334acf678..2882194d4d 100644 --- a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/RefundOrderHandler.php +++ b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/RefundOrderHandler.php @@ -21,6 +21,7 @@ use HiEvents\Services\Application\Handlers\Order\DTO\RefundOrderDTO; use HiEvents\Services\Domain\Order\OrderCancelService; use HiEvents\Services\Domain\Payment\Stripe\StripePaymentIntentRefundService; +use HiEvents\Services\Infrastructure\Stripe\StripeClientFactory; use HiEvents\Values\MoneyValue; use Illuminate\Contracts\Mail\Mailer; use Illuminate\Database\DatabaseManager; @@ -28,15 +29,16 @@ use Symfony\Component\Routing\Exception\ResourceNotFoundException; use Throwable; -readonly class RefundOrderHandler +class RefundOrderHandler { public function __construct( - private StripePaymentIntentRefundService $refundService, - private OrderRepositoryInterface $orderRepository, - private EventRepositoryInterface $eventRepository, - private Mailer $mailer, - private OrderCancelService $orderCancelService, - private DatabaseManager $databaseManager, + private readonly StripePaymentIntentRefundService $refundService, + private readonly OrderRepositoryInterface $orderRepository, + private readonly EventRepositoryInterface $eventRepository, + private readonly Mailer $mailer, + private readonly OrderCancelService $orderCancelService, + private readonly DatabaseManager $databaseManager, + private readonly StripeClientFactory $stripeClientFactory, ) { } @@ -58,7 +60,10 @@ private function fetchOrder(int $eventId, int $orderId): OrderDomainObject ->findFirstWhere(['event_id' => $eventId, 'id' => $orderId]); if (!$order) { - throw new ResourceNotFoundException(); + throw new ResourceNotFoundException(__('Order :id not found for event :eventId', [ + 'id' => $orderId, + 'eventId' => $eventId, + ])); } return $order; @@ -130,9 +135,17 @@ private function refundOrder(RefundOrderDTO $refundOrderDTO): OrderDomainObject $this->orderCancelService->cancelOrder($order); } + // Determine the correct Stripe platform for this refund + // Use the platform that was used for the original payment + $paymentPlatform = $order->getStripePayment()->getStripePlatformEnum(); + + // Create Stripe client for the original payment's platform + $stripeClient = $this->stripeClientFactory->createForPlatform($paymentPlatform); + $this->refundService->refundPayment( amount: $amount, - payment: $order->getStripePayment() + payment: $order->getStripePayment(), + stripeClient: $stripeClient ); if ($refundOrderDTO->notify_buyer) { diff --git a/backend/app/Services/Application/Handlers/Organizer/DTO/SendOrganizerContactMessageDTO.php b/backend/app/Services/Application/Handlers/Organizer/DTO/SendOrganizerContactMessageDTO.php index cd32b90b7d..74789cbabe 100644 --- a/backend/app/Services/Application/Handlers/Organizer/DTO/SendOrganizerContactMessageDTO.php +++ b/backend/app/Services/Application/Handlers/Organizer/DTO/SendOrganizerContactMessageDTO.php @@ -7,11 +7,12 @@ class SendOrganizerContactMessageDTO extends BaseDataObject { public function __construct( - public int $organizer_id, - public int $account_id, + public int $organizer_id, + public ?int $account_id, public string $name, public string $email, public string $message, - ) { + ) + { } } diff --git a/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php b/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php index 78286da8e9..7dceaaef96 100644 --- a/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php +++ b/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php @@ -7,13 +7,14 @@ use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; -use HiEvents\Mail\Attendee\AttendeeTicketMail; +use HiEvents\Services\Domain\Email\MailBuilderService; use Illuminate\Contracts\Mail\Mailer; class SendAttendeeTicketService { public function __construct( - private readonly Mailer $mailer + private readonly Mailer $mailer, + private readonly MailBuilderService $mailBuilderService, ) { } @@ -26,15 +27,17 @@ public function send( OrganizerDomainObject $organizer, ): void { + $mail = $this->mailBuilderService->buildAttendeeTicketMail( + $attendee, + $order, + $event, + $eventSettings, + $organizer + ); + $this->mailer ->to($attendee->getEmail()) ->locale($attendee->getLocale()) - ->send(new AttendeeTicketMail( - order: $order, - attendee: $attendee, - event: $event, - eventSettings: $eventSettings, - organizer: $organizer, - )); + ->send($mail); } } diff --git a/backend/app/Services/Domain/Email/DTO/RenderedEmailTemplateDTO.php b/backend/app/Services/Domain/Email/DTO/RenderedEmailTemplateDTO.php new file mode 100644 index 0000000000..7b66600b91 --- /dev/null +++ b/backend/app/Services/Domain/Email/DTO/RenderedEmailTemplateDTO.php @@ -0,0 +1,16 @@ +emailTemplateRepository->findByTypeWithFallback( + $type, + $accountId, + $eventId, + $organizerId + ); + } + + public function renderTemplate(EmailTemplateDomainObject $template, array $context): RenderedEmailTemplateDTO + { + $renderedSubject = $this->liquidRenderer->render($template->getSubject(), $context); + $renderedBody = $this->liquidRenderer->render($template->getBody(), $context); + + $cta = null; + + // Handle CTA if present + if ($template->getCta()) { + $templateCta = $template->getCta(); + if (isset($templateCta['label'], $templateCta['url_token'])) { + // Replace the URL token with actual value from context + // Handle dot notation (e.g., 'order.url' -> $context['order']['url']) + $ctaUrl = $this->getValueFromDotNotation($context, $templateCta['url_token']) ?? '#'; + $cta = [ + 'label' => $templateCta['label'], + 'url' => $ctaUrl, + ]; + } + } + + return new RenderedEmailTemplateDTO( + subject: $renderedSubject, + body: $renderedBody, + cta: $cta, + ); + } + + /** + * Get default template content + */ + public function getDefaultTemplate(EmailTemplateType $type): array + { + $defaults = $this->getDefaultTemplates(); + $ctaDefaults = $this->getDefaultCTAs(); + + $template = $defaults[$type->value] ?? throw new ResourceNotFoundException('No default template for type ' . $type->value); + + $template['cta'] = $ctaDefaults[$type->value] ?? null; + + return $template; + } + + public function previewTemplate(string $subject, string $body, EmailTemplateType $type, ?array $cta = null): array + { + $context = $this->tokenBuilder->buildPreviewContext($type->value); + + $renderedBody = $this->liquidRenderer->render($body, $context); + + // Add CTA button if provided + if ($cta && isset($cta['label'])) { + $ctaUrl = $this->getValueFromDotNotation($context, $cta['url_token'] ?? '') ?? '#'; + $ctaHtml = sprintf( + '
+ %s +
', + htmlspecialchars($ctaUrl), + htmlspecialchars($cta['label']) + ); + $renderedBody .= $ctaHtml; + } + + return [ + 'subject' => $this->liquidRenderer->render($subject, $context), + 'body' => $renderedBody, + 'context' => $context, // Return context for debugging + ]; + } + + public function validateTemplate(string $subject, string $body): array + { + $errors = []; + + $subjectError = $this->liquidRenderer->getValidationErrors($subject); + if ($subjectError) { + $errors['subject'] = $subjectError; + } + + $bodyError = $this->liquidRenderer->getValidationErrors($body); + if ($bodyError) { + $errors['body'] = $bodyError; + } + + return [ + 'valid' => empty($errors), + 'errors' => $errors, + ]; + } + + /** + * Get value from array using dot notation + * e.g., 'order.url' will get $array['order']['url'] + */ + private function getValueFromDotNotation(array $array, string $key) + { + $keys = explode('.', $key); + $value = $array; + + foreach ($keys as $k) { + if (!isset($value[$k])) { + return null; + } + $value = $value[$k]; + } + + return $value; + } + + private function getDefaultCTAs(): array + { + return [ + EmailTemplateType::ORDER_CONFIRMATION->value => [ + 'label' => __('View Order & Tickets'), + 'url_token' => 'order.url', + ], + EmailTemplateType::ATTENDEE_TICKET->value => [ + 'label' => __('View Ticket'), + 'url_token' => 'ticket.url', + ], + ]; + } + + private function getDefaultTemplates(): array + { + return [ + EmailTemplateType::ORDER_CONFIRMATION->value => [ + 'subject' => 'Your Order is Confirmed! 🎉', + 'body' => <<<'LIQUID' +Your Order is Confirmed! 🎉
+ +{% if order.is_awaiting_offline_payment %} +ℹ️ Payment Pending: 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:
+{% if settings.offline_payment_instructions %} +{{ settings.offline_payment_instructions }}
+{% endif %} + +{% else %} +Congratulations! Your order for {{ event.title }} on {{ event.date }} at {{ event.time }} was successful. Please find your order details below.
+{% endif %} + +Event Details
+Event Name: {{ event.title }}
+Date & Time: {{ event.date }} at {{ event.time }}
+{% if event.full_address %}Location: {{ event.full_address }}
{% endif %} +
+ +{% if settings.post_checkout_message %} +Additional Information
+{{ settings.post_checkout_message }}
+{% endif %} + +Order Summary
+Order Number: {{ order.number }}
+Total Amount: {{ order.total }}
+ +If you have any questions or need assistance, please contact {{ settings.support_email }}.
+ +Best regards,
+{{ organizer.name }} +LIQUID + ], + EmailTemplateType::ATTENDEE_TICKET->value => [ + 'subject' => '🎟️ Your Ticket for {{ event.title }}', + 'body' => <<<'LIQUID' +You're going to {{ event.title }}! 🎉
+ +{% if order.is_awaiting_offline_payment %} +ℹ️ Payment Pending: Your order is pending payment. Tickets have been issued but will not be valid until payment is received.
+{% endif %} + +Hi {{ attendee.name }},
+ +Please find your ticket details below.
+ +Event Information
+Event: {{ event.title }}
+Date: {{ event.date }}
+Time: {{ event.time }}
+{% if event.full_address %}Location: {{ event.full_address }}
{% endif %} +
+ +Your Ticket
+Ticket Type: {{ ticket.name }}
+Price: {{ ticket.price }}
+Attendee: {{ attendee.name }}
+ +💡Remember: Please have your ticket ready when you arrive at the event.
+ +If you have any questions or need assistance, please reply to this email or contact the event organizer at {{ settings.support_email }}.
+ +LIQUID + ], + ]; + } +} diff --git a/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php b/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php new file mode 100644 index 0000000000..de7a6fe6ed --- /dev/null +++ b/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php @@ -0,0 +1,175 @@ +getStartDate(), $event->getTimezone())); + $eventEndDate = $event->getEndDate() ? new Carbon(DateHelper::convertFromUTC($event->getEndDate(), $event->getTimezone())) : null; + + return [ + // Event object + 'event' => [ + 'title' => $event->getTitle(), + 'date' => $eventStartDate->format('F j, Y'), + 'time' => $eventStartDate->format('g:i A'), + 'end_date' => $eventEndDate?->format('F j, Y') ?? '', + 'end_time' => $eventEndDate?->format('g:i A') ?? '', + 'full_address' => $eventSettings->getLocationDetails() ? AddressHelper::formatAddress($eventSettings->getLocationDetails()) : '', + 'location_details' => $eventSettings->getLocationDetails(), + 'description' => $event->getDescription() ?? '', + 'timezone' => $event->getTimezone(), + ], + + // Order object + 'order' => [ + 'url' => sprintf( + Url::getFrontEndUrlFromConfig(Url::ORDER_SUMMARY), + $event->getId(), + $order->getShortId() + ), + 'number' => $order->getPublicId(), + 'total' => Currency::format($order->getTotalGross(), $event->getCurrency()), + 'date' => (new Carbon($order->getCreatedAt()))->format('F j, Y'), + 'currency' => $order->getCurrency(), // added + 'locale' => $order->getLocale(), // added + 'first_name' => $order->getFirstName() ?? '', + 'last_name' => $order->getLastName() ?? '', + 'email' => $order->getEmail() ?? '', + 'is_awaiting_offline_payment' => $order->isOrderAwaitingOfflinePayment(), + 'is_offline_payment' => $order->getPaymentProvider() === PaymentProviders::OFFLINE->value, + ], + + // Organizer object + 'organizer' => [ + 'name' => $organizer->getName() ?? '', + 'email' => $organizer->getEmail() ?? '', + ], + + // Settings object + 'settings' => [ + 'support_email' => $eventSettings->getSupportEmail() ?? $organizer->getEmail() ?? '', + 'offline_payment_instructions' => $eventSettings->getOfflinePaymentInstructions() ?? '', + 'post_checkout_message' => $eventSettings->getPostCheckoutMessage() ?? '', + ], + ]; + } + + public function buildAttendeeTicketContext( + AttendeeDomainObject $attendee, + OrderDomainObject $order, + EventDomainObject $event, + OrganizerDomainObject $organizer, + EventSettingDomainObject $eventSettings + ): array + { + $baseContext = $this->buildOrderConfirmationContext($order, $event, $organizer, $eventSettings); + + /** @var OrderItemDomainObject $orderItem */ + $orderItem = $order->getOrderItems()->first(fn(OrderItemDomainObject $item) => $item->getProductPriceId() === $attendee->getProductPriceId()); + + $ticketPrice = Currency::format($orderItem?->getPrice(), $event->getCurrency()); + $ticketName = $orderItem->getItemName(); + + // Add attendee and ticket objects + $baseContext['attendee'] = [ + 'name' => $attendee->getFirstName() . ' ' . $attendee->getLastName(), + 'email' => $attendee->getEmail() ?? '', + ]; + + $baseContext['ticket'] = [ + 'name' => $ticketName, + 'price' => $ticketPrice, + 'url' => sprintf( + Url::getFrontEndUrlFromConfig(Url::ATTENDEE_TICKET), + $event->getId(), + $attendee->getShortId() + ), + ]; + + return $baseContext; + } + + public function buildPreviewContext(string $templateType): array + { + $baseContext = [ + 'event' => [ + 'title' => __('Summer Music Festival 2024'), + 'date' => 'April 25, 2029', + 'time' => '7:00 PM', + 'end_date' => 'April 26, 2029', + 'end_time' => '11:00 PM', + 'full_address' => __('3 Arena, North Wall Quay, Dublin 1, Ireland'), + 'description' => __('Join us for an unforgettable evening of live music featuring top artists from around the world.'), + 'timezone' => 'UTC', + 'location_details' => [ + 'venue_name' => '3 Arena', + 'address_line_1' => 'North Wall Quay', + 'address_line_2' => '', + 'city' => 'Dublin', + 'state_or_region' => 'Dublin 1', + 'zip_or_postal_code' => 'D01 T0X4', + 'country' => 'IE', + ] + ], + 'order' => [ + 'url' => 'https://example.com/order/ABC123', + 'number' => IdHelper::publicId(IdHelper::ORDER_PREFIX), + 'total' => '$150.00', + 'date' => 'January 10, 2024', + 'first_name' => 'John', + 'last_name' => 'Smith', + 'email' => 'john@example.com', + 'is_awaiting_offline_payment' => false, + 'is_offline_payment' => false, + 'locale' => Locale::EN->value, + 'currency' => 'USD' + ], + 'organizer' => [ + 'name' => 'ACME Events Inc.', + 'email' => 'contact@example.com', + ], + 'settings' => [ + 'support_email' => 'support@example.com', + 'offline_payment_instructions' => __('Please transfer the total amount to the following bank account within 5 business days.'), + 'post_checkout_message' => __('Thank you for your purchase! We look forward to seeing you at the event.'), + ], + ]; + + if ($templateType === 'attendee_ticket') { + $baseContext['attendee'] = [ + 'name' => 'John Smith', + 'email' => 'john@example.com', + ]; + $baseContext['ticket'] = [ + 'name' => 'VIP Pass', + 'price' => '$75.00', + 'url' => 'https://example.com/ticket/XYZ789', + ]; + } + + return $baseContext; + } +} diff --git a/backend/app/Services/Domain/Email/MailBuilderService.php b/backend/app/Services/Domain/Email/MailBuilderService.php new file mode 100644 index 0000000000..59ef8806b3 --- /dev/null +++ b/backend/app/Services/Domain/Email/MailBuilderService.php @@ -0,0 +1,128 @@ +renderAttendeeTicketTemplate( + $attendee, + $order, + $event, + $eventSettings, + $organizer + ); + + return new AttendeeTicketMail( + order: $order, + attendee: $attendee, + event: $event, + eventSettings: $eventSettings, + organizer: $organizer, + renderedTemplate: $renderedTemplate, + ); + } + + public function buildOrderSummaryMail( + OrderDomainObject $order, + EventDomainObject $event, + EventSettingDomainObject $eventSettings, + OrganizerDomainObject $organizer, + ?InvoiceDomainObject $invoice = null + ): OrderSummary { + $renderedTemplate = $this->renderOrderSummaryTemplate( + $order, + $event, + $eventSettings, + $organizer + ); + + return new OrderSummary( + order: $order, + event: $event, + organizer: $organizer, + eventSettings: $eventSettings, + invoice: $invoice, + renderedTemplate: $renderedTemplate, + ); + } + + private function renderAttendeeTicketTemplate( + AttendeeDomainObject $attendee, + OrderDomainObject $order, + EventDomainObject $event, + EventSettingDomainObject $eventSettings, + OrganizerDomainObject $organizer + ): ?RenderedEmailTemplateDTO { + $template = $this->emailTemplateService->getTemplateByType( + type: EmailTemplateType::ATTENDEE_TICKET, + accountId: $event->getAccountId(), + eventId: $event->getId(), + organizerId: $organizer->getId() + ); + + if (!$template) { + return null; + } + + $context = $this->tokenContextBuilder->buildAttendeeTicketContext( + $attendee, + $order, + $event, + $organizer, + $eventSettings + ); + + return $this->emailTemplateService->renderTemplate($template, $context); + } + + private function renderOrderSummaryTemplate( + OrderDomainObject $order, + EventDomainObject $event, + EventSettingDomainObject $eventSettings, + OrganizerDomainObject $organizer + ): ?RenderedEmailTemplateDTO { + $template = $this->emailTemplateService->getTemplateByType( + type: EmailTemplateType::ORDER_CONFIRMATION, + accountId: $event->getAccountId(), + eventId: $event->getId(), + organizerId: $organizer->getId() + ); + + if (!$template) { + return null; + } + + $context = $this->tokenContextBuilder->buildOrderConfirmationContext( + $order, + $event, + $organizer, + $eventSettings + ); + + return $this->emailTemplateService->renderTemplate($template, $context); + } +} diff --git a/backend/app/Services/Domain/Event/CreateEventImageService.php b/backend/app/Services/Domain/Event/CreateEventImageService.php index 33d9094b77..2b448cea6d 100644 --- a/backend/app/Services/Domain/Event/CreateEventImageService.php +++ b/backend/app/Services/Domain/Event/CreateEventImageService.php @@ -11,6 +11,9 @@ use Illuminate\Http\UploadedFile; use Throwable; +/** + * @deprecated use CreateImageAction + */ class CreateEventImageService { public function __construct( diff --git a/backend/app/Services/Domain/Event/DTO/DuplicateEventDataDTO.php b/backend/app/Services/Domain/Event/DTO/DuplicateEventDataDTO.php index e93a5ca73e..003ac4ba20 100644 --- a/backend/app/Services/Domain/Event/DTO/DuplicateEventDataDTO.php +++ b/backend/app/Services/Domain/Event/DTO/DuplicateEventDataDTO.php @@ -18,6 +18,7 @@ public function __construct( public bool $duplicateCapacityAssignments = true, public bool $duplicateCheckInLists = true, public bool $duplicateEventCoverImage = true, + public bool $duplicateTicketLogo = true, public bool $duplicateWebhooks = true, public bool $duplicateAffiliates = true, public ?string $description = null, diff --git a/backend/app/Services/Domain/Event/DuplicateEventService.php b/backend/app/Services/Domain/Event/DuplicateEventService.php index 692ce391e7..f7c0bdef03 100644 --- a/backend/app/Services/Domain/Event/DuplicateEventService.php +++ b/backend/app/Services/Domain/Event/DuplicateEventService.php @@ -68,6 +68,7 @@ public function duplicateEvent( bool $duplicateCapacityAssignments = true, bool $duplicateCheckInLists = true, bool $duplicateEventCoverImage = true, + bool $duplicateTicketLogo = true, bool $duplicateWebhooks = true, bool $duplicateAffiliates = true, ?string $description = null, @@ -112,6 +113,10 @@ public function duplicateEvent( $this->cloneEventCoverImage($event, $newEvent->getId()); } + if ($duplicateTicketLogo) { + $this->cloneTicketLogo($event, $newEvent->getId()); + } + if ($duplicateWebhooks) { $this->duplicateWebhooks($event, $newEvent); } @@ -329,6 +334,24 @@ private function cloneEventCoverImage(EventDomainObject $event, int $newEventId) } } + private function cloneTicketLogo(EventDomainObject $event, int $newEventId): void + { + /** @var ImageDomainObject $ticketLogo */ + $ticketLogo = $event->getImages()?->first(fn(ImageDomainObject $image) => $image->getType() === ImageType::TICKET_LOGO->name); + if ($ticketLogo) { + $this->imageRepository->create([ + 'entity_id' => $newEventId, + 'entity_type' => EventDomainObject::class, + 'type' => ImageType::TICKET_LOGO->name, + 'disk' => $ticketLogo->getDisk(), + 'path' => $ticketLogo->getPath(), + 'filename' => $ticketLogo->getFileName(), + 'size' => $ticketLogo->getSize(), + 'mime_type' => $ticketLogo->getMimeType(), + ]); + } + } + private function getEventWithRelations(string $eventId, string $accountId): EventDomainObject { return $this->eventRepository diff --git a/backend/app/Services/Domain/EventStatistics/EventStatisticsCancellationService.php b/backend/app/Services/Domain/EventStatistics/EventStatisticsCancellationService.php new file mode 100644 index 0000000000..c0e84410a0 --- /dev/null +++ b/backend/app/Services/Domain/EventStatistics/EventStatisticsCancellationService.php @@ -0,0 +1,414 @@ +orderRepository + ->loadRelation(OrderItemDomainObject::class) + ->findById($order->getId()); + + // Check if statistics have already been decremented for this order + if ($order->getStatisticsDecrementedAt() !== null) { + $this->logger->info( + 'Statistics already decremented for cancelled order', + [ + 'order_id' => $order->getId(), + 'event_id' => $order->getEventId(), + 'decremented_at' => $order->getStatisticsDecrementedAt(), + ] + ); + return; + } + + $this->retrier->retry( + callableAction: function (int $attempt) use ($order): void { + $this->databaseManager->transaction(function () use ($order, $attempt): void { + $currentOrder = $this->orderRepository->findById($order->getId()); + if ($currentOrder->getStatisticsDecrementedAt() !== null) { + $this->logger->info( + 'Statistics already decremented for cancelled order (checked within transaction)', + [ + 'order_id' => $order->getId(), + 'event_id' => $order->getEventId(), + 'decremented_at' => $currentOrder->getStatisticsDecrementedAt(), + ] + ); + return; + } + + // Calculate counts to decrement + $counts = $this->calculateDecrementCounts($order); + + // Decrement aggregate statistics + $this->decrementAggregateStatistics($order, $counts, $attempt); + + // Decrement daily statistics + $this->decrementDailyStatistics($order, $counts, $attempt); + + // Mark statistics as decremented + $this->markStatisticsAsDecremented($order); + }); + }, + onFailure: function (int $attempt, Throwable $e) use ($order): void { + $this->logger->error( + 'Failed to decrement event statistics for cancelled order after multiple attempts', + [ + 'event_id' => $order->getEventId(), + 'order_id' => $order->getId(), + 'attempts' => $attempt, + 'exception' => $e::class, + 'message' => $e->getMessage(), + ] + ); + }, + retryOn: [EventStatisticsVersionMismatchException::class] + ); + } + + /** + * Decrement statistics for a cancelled attendee + * + * @throws EventStatisticsVersionMismatchException + * @throws Throwable + */ + public function decrementForCancelledAttendee(int $eventId, string $orderDate, int $attendeeCount = 1): void + { + $this->retrier->retry( + callableAction: function () use ($eventId, $orderDate, $attendeeCount): void { + $this->databaseManager->transaction(function () use ($eventId, $orderDate, $attendeeCount): void { + // Decrement aggregate statistics + $this->decrementAggregateAttendeeStatistics($eventId, $attendeeCount); + + // Decrement daily statistics + $this->decrementDailyAttendeeStatistics($eventId, $orderDate, $attendeeCount); + }); + }, + onFailure: function (int $attempt, Throwable $e) use ($eventId, $orderDate, $attendeeCount): void { + $this->logger->error( + 'Failed to decrement event statistics for cancelled attendee after multiple attempts', + [ + 'event_id' => $eventId, + 'order_date' => $orderDate, + 'attendee_count' => $attendeeCount, + 'attempts' => $attempt, + 'exception' => $e::class, + 'message' => $e->getMessage(), + ] + ); + }, + retryOn: [EventStatisticsVersionMismatchException::class] + ); + } + + /** + * Calculate the counts that need to be decremented from statistics + */ + private function calculateDecrementCounts(OrderDomainObject $order): array + { + // Get attendees that are currently active or awaiting payment (not already cancelled) + $activeAttendees = $this->attendeeRepository->findWhereIn( + field: 'status', + values: [AttendeeStatus::ACTIVE->name, AttendeeStatus::AWAITING_PAYMENT->name], + additionalWhere: ['order_id' => $order->getId()], + ); + + $activeAttendeeCount = $activeAttendees->count(); + + // Products sold should be the full order quantities - products don't get "uncancelled" + // when individual attendees are cancelled, only when the entire order is cancelled + $productsSold = $order->getOrderItems() + ?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()) ?? 0; + + // Attendees registered should only be the currently active attendees + // to avoid over-decrementing when some attendees were already cancelled individually + $attendeesRegistered = $activeAttendeeCount; + + return [ + 'active_attendees' => $activeAttendeeCount, + 'products_sold' => $productsSold, + 'attendees_registered' => $attendeesRegistered, + ]; + } + + /** + * Decrement aggregate event statistics for cancelled order + * + * @throws EventStatisticsVersionMismatchException + */ + private function decrementAggregateStatistics(OrderDomainObject $order, array $counts, int $attempt): void + { + $eventStatistics = $this->eventStatisticsRepository->findFirstWhere([ + 'event_id' => $order->getEventId(), + ]); + + if (!$eventStatistics) { + throw new ResourceNotFoundException('Event statistics not found for event ' . $order->getEventId()); + } + + $updates = [ + 'attendees_registered' => max(0, $eventStatistics->getAttendeesRegistered() - $counts['attendees_registered']), + 'products_sold' => max(0, $eventStatistics->getProductsSold() - $counts['products_sold']), + 'orders_created' => max(0, $eventStatistics->getOrdersCreated() - 1), + 'orders_cancelled' => ($eventStatistics->getOrdersCancelled() ?? 0) + 1, + 'version' => $eventStatistics->getVersion() + 1, + ]; + + $updated = $this->eventStatisticsRepository->updateWhere( + attributes: $updates, + where: [ + 'id' => $eventStatistics->getId(), + 'version' => $eventStatistics->getVersion(), + ] + ); + + if ($updated === 0) { + throw new EventStatisticsVersionMismatchException( + 'Event statistics version mismatch. Expected version ' + . $eventStatistics->getVersion() . ' but it was already updated.' + ); + } + + $this->logger->info( + 'Event aggregate statistics decremented for cancelled order', + [ + 'event_id' => $order->getEventId(), + 'order_id' => $order->getId(), + 'attendees_decremented' => $counts['attendees_registered'], + 'products_decremented' => $counts['products_sold'], + 'orders_cancelled_total' => ($eventStatistics->getOrdersCancelled() ?? 0) + 1, + 'attempt' => $attempt, + 'new_version' => $eventStatistics->getVersion() + 1, + ] + ); + } + + /** + * Decrement aggregate event statistics for cancelled attendee + * + * @throws EventStatisticsVersionMismatchException + */ + private function decrementAggregateAttendeeStatistics(int $eventId, int $attendeeCount): void + { + $eventStatistics = $this->eventStatisticsRepository->findFirstWhere([ + 'event_id' => $eventId, + ]); + + if (!$eventStatistics) { + throw new ResourceNotFoundException('Event statistics not found for event ' . $eventId); + } + + // Only decrement attendees_registered for individual attendee cancellations + // products_sold should NOT be affected as the product was still sold + $updates = [ + 'attendees_registered' => max(0, $eventStatistics->getAttendeesRegistered() - $attendeeCount), + 'version' => $eventStatistics->getVersion() + 1, + ]; + + $updated = $this->eventStatisticsRepository->updateWhere( + attributes: $updates, + where: [ + 'id' => $eventStatistics->getId(), + 'version' => $eventStatistics->getVersion(), + ] + ); + + if ($updated === 0) { + throw new EventStatisticsVersionMismatchException( + 'Event statistics version mismatch. Expected version ' + . $eventStatistics->getVersion() . ' but it was already updated.' + ); + } + + $this->logger->info( + 'Event aggregate statistics decremented for cancelled attendee', + [ + 'event_id' => $eventId, + 'attendees_decremented' => $attendeeCount, + 'products_affected' => 0, // Products sold not affected by individual attendee cancellations + 'new_version' => $eventStatistics->getVersion() + 1, + ] + ); + } + + /** + * Decrement daily event statistics for cancelled order + * + * @throws EventStatisticsVersionMismatchException + */ + private function decrementDailyStatistics(OrderDomainObject $order, array $counts, int $attempt): void + { + $orderDate = (new Carbon($order->getCreatedAt()))->format('Y-m-d'); + + $eventDailyStatistic = $this->eventDailyStatisticRepository->findFirstWhere([ + 'event_id' => $order->getEventId(), + 'date' => $orderDate, + ]); + + if (!$eventDailyStatistic) { + $this->logger->warning( + 'Event daily statistics not found for event, skipping daily decrement', + [ + 'event_id' => $order->getEventId(), + 'date' => $orderDate, + ] + ); + return; + } + + $updates = [ + 'attendees_registered' => max(0, $eventDailyStatistic->getAttendeesRegistered() - $counts['attendees_registered']), + 'products_sold' => max(0, $eventDailyStatistic->getProductsSold() - $counts['products_sold']), + 'orders_created' => max(0, $eventDailyStatistic->getOrdersCreated() - 1), + 'orders_cancelled' => ($eventDailyStatistic->getOrdersCancelled() ?? 0) + 1, + 'version' => $eventDailyStatistic->getVersion() + 1, + ]; + + $updated = $this->eventDailyStatisticRepository->updateWhere( + attributes: $updates, + where: [ + 'event_id' => $order->getEventId(), + 'date' => $orderDate, + 'version' => $eventDailyStatistic->getVersion(), + ] + ); + + if ($updated === 0) { + throw new EventStatisticsVersionMismatchException( + 'Event daily statistics version mismatch. Expected version ' + . $eventDailyStatistic->getVersion() . ' but it was already updated.' + ); + } + + $this->logger->info( + 'Event daily statistics decremented for cancelled order', + [ + 'event_id' => $order->getEventId(), + 'order_id' => $order->getId(), + 'date' => $orderDate, + 'attendees_decremented' => $counts['attendees_registered'], + 'products_decremented' => $counts['products_sold'], + 'orders_cancelled_total' => ($eventDailyStatistic->getOrdersCancelled() ?? 0) + 1, + 'attempt' => $attempt, + 'new_version' => $eventDailyStatistic->getVersion() + 1, + ] + ); + } + + /** + * Decrement daily event statistics for cancelled attendee + * + * @throws EventStatisticsVersionMismatchException + */ + private function decrementDailyAttendeeStatistics(int $eventId, string $orderDate, int $attendeeCount): void + { + $formattedDate = (new Carbon($orderDate))->format('Y-m-d'); + + $eventDailyStatistic = $this->eventDailyStatisticRepository->findFirstWhere([ + 'event_id' => $eventId, + 'date' => $formattedDate, + ]); + + if (!$eventDailyStatistic) { + $this->logger->warning( + 'Event daily statistics not found for event, skipping daily decrement for cancelled attendee', + [ + 'event_id' => $eventId, + 'date' => $formattedDate, + ] + ); + return; + } + + // Only decrement attendees_registered for individual attendee cancellations + // products_sold should NOT be affected as the product was still sold + $updates = [ + 'attendees_registered' => max(0, $eventDailyStatistic->getAttendeesRegistered() - $attendeeCount), + 'version' => $eventDailyStatistic->getVersion() + 1, + ]; + + $updated = $this->eventDailyStatisticRepository->updateWhere( + attributes: $updates, + where: [ + 'event_id' => $eventId, + 'date' => $formattedDate, + 'version' => $eventDailyStatistic->getVersion(), + ] + ); + + if ($updated === 0) { + throw new EventStatisticsVersionMismatchException( + 'Event daily statistics version mismatch. Expected version ' + . $eventDailyStatistic->getVersion() . ' but it was already updated.' + ); + } + + $this->logger->info( + 'Event daily statistics decremented for cancelled attendee', + [ + 'event_id' => $eventId, + 'date' => $formattedDate, + 'attendees_decremented' => $attendeeCount, + 'products_affected' => 0, // Products sold not affected by individual attendee cancellations + 'new_version' => $eventDailyStatistic->getVersion() + 1, + ] + ); + } + + /** + * Mark that statistics have been decremented for this order + */ + private function markStatisticsAsDecremented(OrderDomainObject $order): void + { + $this->orderRepository->updateFromArray($order->getId(), [ + OrderDomainObjectAbstract::STATISTICS_DECREMENTED_AT => now(), + ]); + + $this->logger->info( + 'Order marked as statistics decremented', + [ + 'order_id' => $order->getId(), + 'event_id' => $order->getEventId(), + 'decremented_at' => now()->toIso8601String(), + ] + ); + } +} diff --git a/backend/app/Services/Domain/EventStatistics/EventStatisticsIncrementService.php b/backend/app/Services/Domain/EventStatistics/EventStatisticsIncrementService.php new file mode 100644 index 0000000000..47921a2799 --- /dev/null +++ b/backend/app/Services/Domain/EventStatistics/EventStatisticsIncrementService.php @@ -0,0 +1,300 @@ +orderRepository + ->loadRelation(OrderItemDomainObject::class) + ->findById($order->getId()); + + $this->retrier->retry( + callableAction: function (int $attempt) use ($order): void { + $this->databaseManager->transaction(function () use ($order, $attempt): void { + $this->incrementAggregateStatistics($order, $attempt); + $this->incrementDailyStatistics($order, $attempt); + $this->incrementPromoCodeUsage($order); + $this->incrementProductStatistics($order); + }); + }, + onFailure: function (int $attempt, Throwable $e) use ($order): void { + $this->logger->error( + 'Failed to increment event statistics for order after multiple attempts', + [ + 'event_id' => $order->getEventId(), + 'order_id' => $order->getId(), + 'attempts' => $attempt, + 'exception' => $e::class, + 'message' => $e->getMessage(), + ] + ); + }, + retryOn: [EventStatisticsVersionMismatchException::class] + ); + } + + /** + * Increment aggregate event statistics + * + * @throws EventStatisticsVersionMismatchException + */ + private function incrementAggregateStatistics(OrderDomainObject $order): void + { + $eventStatistics = $this->eventStatisticsRepository->findFirstWhere([ + 'event_id' => $order->getEventId(), + ]); + + $productsSold = $order->getOrderItems() + ?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()) ?? 0; + + $attendeesRegistered = $order->getTicketOrderItems() + ?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()) ?? 0; + + if ($eventStatistics === null) { + $this->eventStatisticsRepository->create([ + 'event_id' => $order->getEventId(), + 'products_sold' => $productsSold, + 'attendees_registered' => $attendeesRegistered, + 'sales_total_gross' => $order->getTotalGross(), + 'sales_total_before_additions' => $order->getTotalBeforeAdditions(), + 'total_tax' => $order->getTotalTax(), + 'total_fee' => $order->getTotalFee(), + 'orders_created' => 1, + 'orders_cancelled' => 0, + ]); + + $this->logger->info( + 'Event aggregate statistics created for new event', + [ + 'event_id' => $order->getEventId(), + 'order_id' => $order->getId(), + 'products_sold' => $productsSold, + 'attendees_registered' => $attendeesRegistered, + ] + ); + + return; + } + + $updates = [ + 'products_sold' => $eventStatistics->getProductsSold() + $productsSold, + 'attendees_registered' => $eventStatistics->getAttendeesRegistered() + $attendeesRegistered, + 'sales_total_gross' => $eventStatistics->getSalesTotalGross() + $order->getTotalGross(), + 'sales_total_before_additions' => $eventStatistics->getSalesTotalBeforeAdditions() + $order->getTotalBeforeAdditions(), + 'total_tax' => $eventStatistics->getTotalTax() + $order->getTotalTax(), + 'total_fee' => $eventStatistics->getTotalFee() + $order->getTotalFee(), + 'orders_created' => $eventStatistics->getOrdersCreated() + 1, + 'version' => $eventStatistics->getVersion() + 1, + ]; + + $updated = $this->eventStatisticsRepository->updateWhere( + attributes: $updates, + where: [ + 'event_id' => $order->getEventId(), + 'version' => $eventStatistics->getVersion(), + ] + ); + + if ($updated === 0) { + throw new EventStatisticsVersionMismatchException( + 'Event statistics version mismatch. Expected version ' + . $eventStatistics->getVersion() . ' but it was already updated.' + ); + } + + $this->logger->info( + 'Event aggregate statistics incremented for order', + [ + 'event_id' => $order->getEventId(), + 'order_id' => $order->getId(), + 'products_sold' => $productsSold, + 'attendees_registered' => $attendeesRegistered, + 'new_version' => $eventStatistics->getVersion() + 1, + ] + ); + } + + /** + * Increment daily event statistics + * + * @throws EventStatisticsVersionMismatchException + */ + private function incrementDailyStatistics(OrderDomainObject $order): void + { + $orderDate = (new Carbon($order->getCreatedAt()))->format('Y-m-d'); + + $eventDailyStatistic = $this->eventDailyStatisticRepository->findFirstWhere([ + 'event_id' => $order->getEventId(), + 'date' => $orderDate, + ]); + + $productsSold = $order->getOrderItems() + ?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()) ?? 0; + + $attendeesRegistered = $order->getTicketOrderItems() + ?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()) ?? 0; + + if ($eventDailyStatistic === null) { + $this->eventDailyStatisticRepository->create([ + 'event_id' => $order->getEventId(), + 'date' => $orderDate, + 'products_sold' => $productsSold, + 'attendees_registered' => $attendeesRegistered, + 'sales_total_gross' => $order->getTotalGross(), + 'sales_total_before_additions' => $order->getTotalBeforeAdditions(), + 'total_tax' => $order->getTotalTax(), + 'total_fee' => $order->getTotalFee(), + 'orders_created' => 1, + 'orders_cancelled' => 0, + ]); + + $this->logger->info( + 'Event daily statistics created for new date', + [ + 'event_id' => $order->getEventId(), + 'order_id' => $order->getId(), + 'date' => $orderDate, + 'products_sold' => $productsSold, + 'attendees_registered' => $attendeesRegistered, + ] + ); + + return; + } + + $updates = [ + 'products_sold' => $eventDailyStatistic->getProductsSold() + $productsSold, + 'attendees_registered' => $eventDailyStatistic->getAttendeesRegistered() + $attendeesRegistered, + 'sales_total_gross' => $eventDailyStatistic->getSalesTotalGross() + $order->getTotalGross(), + 'sales_total_before_additions' => $eventDailyStatistic->getSalesTotalBeforeAdditions() + $order->getTotalBeforeAdditions(), + 'total_tax' => $eventDailyStatistic->getTotalTax() + $order->getTotalTax(), + 'total_fee' => $eventDailyStatistic->getTotalFee() + $order->getTotalFee(), + 'orders_created' => $eventDailyStatistic->getOrdersCreated() + 1, + 'version' => $eventDailyStatistic->getVersion() + 1, + ]; + + $updated = $this->eventDailyStatisticRepository->updateWhere( + attributes: $updates, + where: [ + 'event_id' => $order->getEventId(), + 'date' => $orderDate, + 'version' => $eventDailyStatistic->getVersion(), + ], + ); + + if ($updated === 0) { + throw new EventStatisticsVersionMismatchException( + 'Event daily statistics version mismatch. Expected version ' + . $eventDailyStatistic->getVersion() . ' but it was already updated.' + ); + } + + $this->logger->info( + 'Event daily statistics incremented for order', + [ + 'event_id' => $order->getEventId(), + 'order_id' => $order->getId(), + 'date' => $orderDate, + 'products_sold' => $productsSold, + 'attendees_registered' => $attendeesRegistered, + 'new_version' => $eventDailyStatistic->getVersion() + 1, + ] + ); + } + + /** + * Increment promo code usage counts + */ + private function incrementPromoCodeUsage(OrderDomainObject $order): void + { + if ($order->getPromoCodeId() === null) { + return; + } + + $this->promoCodeRepository->increment( + id: $order->getPromoCodeId(), + column: PromoCodeDomainObjectAbstract::ORDER_USAGE_COUNT, + ); + + $attendeeCount = $order->getOrderItems() + ?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()) ?? 0; + + if ($attendeeCount > 0) { + $this->promoCodeRepository->increment( + id: $order->getPromoCodeId(), + column: PromoCodeDomainObjectAbstract::ATTENDEE_USAGE_COUNT, + amount: $attendeeCount, + ); + } + + $this->logger->info( + 'Promo code usage incremented', + [ + 'promo_code_id' => $order->getPromoCodeId(), + 'order_id' => $order->getId(), + 'attendee_count' => $attendeeCount, + ] + ); + } + + /** + * Increment product sales volume statistics + */ + private function incrementProductStatistics(OrderDomainObject $order): void + { + foreach ($order->getOrderItems() as $orderItem) { + $this->productRepository->increment( + $orderItem->getProductId(), + ProductDomainObjectAbstract::SALES_VOLUME, + $orderItem->getTotalBeforeAdditions(), + ); + } + + $this->logger->info( + 'Product sales volume incremented', + [ + 'order_id' => $order->getId(), + 'product_count' => $order->getOrderItems()->count(), + ] + ); + } +} diff --git a/backend/app/Services/Domain/EventStatistics/EventStatisticsRefundService.php b/backend/app/Services/Domain/EventStatistics/EventStatisticsRefundService.php new file mode 100644 index 0000000000..75dbfa6a7e --- /dev/null +++ b/backend/app/Services/Domain/EventStatistics/EventStatisticsRefundService.php @@ -0,0 +1,144 @@ +updateAggregateStatisticsForRefund($order, $refundAmount); + $this->updateDailyStatisticsForRefund($order, $refundAmount); + } + + /** + * Update aggregate statistics for a refund + */ + private function updateAggregateStatisticsForRefund(OrderDomainObject $order, MoneyValue $refundAmount): void + { + $eventStatistics = $this->eventStatisticsRepository->findFirstWhere([ + 'event_id' => $order->getEventId(), + ]); + + if (!$eventStatistics) { + throw new ResourceNotFoundException("Event statistics not found for event {$order->getEventId()}"); + } + + // Calculate the proportion of the refund to the total order amount + $refundProportion = $refundAmount->toFloat() / $order->getTotalGross(); + + // Adjust the total_tax and total_fee based on the refund proportion + $adjustedTotalTax = $eventStatistics->getTotalTax() - ($order->getTotalTax() * $refundProportion); + $adjustedTotalFee = $eventStatistics->getTotalFee() - ($order->getTotalFee() * $refundProportion); + + $updates = [ + 'sales_total_gross' => $eventStatistics->getSalesTotalGross() - $refundAmount->toFloat(), + 'total_refunded' => $eventStatistics->getTotalRefunded() + $refundAmount->toFloat(), + 'total_tax' => max(0, $adjustedTotalTax), + 'total_fee' => max(0, $adjustedTotalFee), + ]; + + $this->eventStatisticsRepository->updateWhere( + attributes: $updates, + where: [ + 'event_id' => $order->getEventId(), + ] + ); + + $this->logger->info( + 'Event aggregate statistics updated for refund', + [ + 'event_id' => $order->getEventId(), + 'order_id' => $order->getId(), + 'refund_amount' => $refundAmount->toFloat(), + 'refund_proportion' => $refundProportion, + 'original_total_gross' => $eventStatistics->getSalesTotalGross(), + 'original_total_refunded' => $eventStatistics->getTotalRefunded(), + 'tax_adjustment' => $order->getTotalTax() * $refundProportion, + 'fee_adjustment' => $order->getTotalFee() * $refundProportion, + ] + ); + } + + /** + * Update daily statistics for a refund + */ + private function updateDailyStatisticsForRefund(OrderDomainObject $order, MoneyValue $refundAmount): void + { + $orderDate = (new Carbon($order->getCreatedAt()))->format('Y-m-d'); + + $eventDailyStatistic = $this->eventDailyStatisticRepository->findFirstWhere([ + 'event_id' => $order->getEventId(), + 'date' => $orderDate, + ]); + + if ($eventDailyStatistic === null) { + $this->logger->warning( + 'Event daily statistics not found for refund', + [ + 'event_id' => $order->getEventId(), + 'date' => $orderDate, + 'order_id' => $order->getId(), + ] + ); + return; + } + + // Calculate the proportion of the refund to the total order amount + $refundProportion = $refundAmount->toFloat() / $order->getTotalGross(); + + // Adjust the total_tax and total_fee based on the refund proportion + $adjustedTotalTax = $eventDailyStatistic->getTotalTax() - ($order->getTotalTax() * $refundProportion); + $adjustedTotalFee = $eventDailyStatistic->getTotalFee() - ($order->getTotalFee() * $refundProportion); + + $updates = [ + 'sales_total_gross' => $eventDailyStatistic->getSalesTotalGross() - $refundAmount->toFloat(), + 'total_refunded' => $eventDailyStatistic->getTotalRefunded() + $refundAmount->toFloat(), + 'total_tax' => max(0, $adjustedTotalTax), + 'total_fee' => max(0, $adjustedTotalFee), + ]; + + $this->eventDailyStatisticRepository->updateWhere( + attributes: $updates, + where: [ + 'event_id' => $order->getEventId(), + 'date' => $orderDate, + ] + ); + + $this->logger->info( + 'Event daily statistics updated for refund', + [ + 'event_id' => $order->getEventId(), + 'order_id' => $order->getId(), + 'date' => $orderDate, + 'refund_amount' => $refundAmount->toFloat(), + 'refund_proportion' => $refundProportion, + 'original_total_gross' => $eventDailyStatistic->getSalesTotalGross(), + 'original_total_refunded' => $eventDailyStatistic->getTotalRefunded(), + 'tax_adjustment' => $order->getTotalTax() * $refundProportion, + 'fee_adjustment' => $order->getTotalFee() * $refundProportion, + ] + ); + } +} diff --git a/backend/app/Services/Domain/EventStatistics/EventStatisticsUpdateService.php b/backend/app/Services/Domain/EventStatistics/EventStatisticsUpdateService.php deleted file mode 100644 index 24ea7afec2..0000000000 --- a/backend/app/Services/Domain/EventStatistics/EventStatisticsUpdateService.php +++ /dev/null @@ -1,304 +0,0 @@ -orderRepository - ->loadRelation(OrderItemDomainObject::class) - ->findById($order->getId()); - - $this->databaseManager->transaction(function () use ($order) { - $this->updateEventStats($order); - $this->updateEventDailyStats($order); - $this->updatePromoCodeCounts($order); - $this->updateProductStatistics($order); - }); - } - - public function updateEventStatsTotalRefunded(OrderDomainObject $order, MoneyValue $amount): void - { - $this->updateAggregateStatsWithRefund($order, $amount); - $this->updateEventDailyStatsWithRefund($order, $amount); - } - - private function updateEventDailyStatsWithRefund(OrderDomainObject $order, MoneyValue $amount): void - { - $eventDailyStatistic = $this->eventDailyStatisticRepository->findFirstWhere( - where: [ - 'event_id' => $order->getEventId(), - 'date' => (new Carbon($order->getCreatedAt()))->format('Y-m-d'), - ] - ); - - if ($eventDailyStatistic === null) { - throw new ResourceNotFoundException("Event daily statistics not found."); - } - - // Calculate the proportion of the refund to the total order amount - $refundProportion = $amount->toFloat() / $order->getTotalGross(); - - // Adjust the total_tax and total_fee based on the refund proportion - $adjustedTotalTax = $eventDailyStatistic->getTotalTax() - ($order->getTotalTax() * $refundProportion); - $adjustedTotalFee = $eventDailyStatistic->getTotalFee() - ($order->getTotalFee() * $refundProportion); - - // Update the event daily statistics with the new values - $this->eventDailyStatisticRepository->updateWhere( - attributes: [ - 'sales_total_gross' => $eventDailyStatistic->getSalesTotalGross() - $amount->toFloat(), - 'total_refunded' => $eventDailyStatistic->getTotalRefunded() + $amount->toFloat(), - 'total_tax' => $adjustedTotalTax, - 'total_fee' => $adjustedTotalFee, - ], - where: [ - 'event_id' => $order->getEventId(), - 'date' => (new Carbon($order->getCreatedAt()))->format('Y-m-d'), - ] - ); - - $this->logger->info( - message: __('Event daily statistics updated for event :event_id with total refunded amount of :amount', [ - 'event_id' => $order->getEventId(), - 'amount' => $amount->toFloat(), - ]), - context: [ - 'event_id' => $order->getEventId(), - 'amount' => $amount->toFloat(), - 'original_total_gross' => $eventDailyStatistic->getSalesTotalGross(), - 'original_total_refunded' => $eventDailyStatistic->getTotalRefunded(), - 'original_total_tax' => $eventDailyStatistic->getTotalTax(), - 'original_total_fee' => $eventDailyStatistic->getTotalFee(), - 'tax_refunded' => $adjustedTotalTax, - 'fee_refunded' => $adjustedTotalFee, - ]); - } - - private function updatePromoCodeCounts(OrderDomainObject $order): void - { - if ($order->getPromoCodeId() !== null) { - $this->promoCodeRepository->increment( - id: $order->getPromoCodeId(), - column: PromoCodeDomainObjectAbstract::ORDER_USAGE_COUNT, - ); - $this->promoCodeRepository->increment( - id: $order->getPromoCodeId(), - column: PromoCodeDomainObjectAbstract::ATTENDEE_USAGE_COUNT, - amount: $order->getOrderItems()?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()) ?: 0, - ); - } - } - - private function updateProductStatistics(OrderDomainObject $order): void - { - foreach ($order->getOrderItems() as $orderItem) { - $this->productRepository->increment( - $orderItem->getProductId(), - ProductDomainObjectAbstract::SALES_VOLUME, - $orderItem->getTotalBeforeAdditions(), - ); - } - } - - /** - * @param OrderDomainObject $order - * @return void - * @throws EventStatisticsVersionMismatchException - */ - private function updateEventStats(OrderDomainObject $order): void - { - $eventStatistics = $this->eventStatisticsRepository->findFirstWhere( - where: [ - 'event_id' => $order->getEventId(), - ] - ); - - if ($eventStatistics === null) { - $this->eventStatisticsRepository->create([ - 'event_id' => $order->getEventId(), - '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(), - 'total_fee' => $order->getTotalFee(), - 'orders_created' => 1, - ]); - - return; - } - - $update = $this->eventStatisticsRepository->updateWhere( - attributes: [ - '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(), - 'total_tax' => $eventStatistics->getTotalTax() + $order->getTotalTax(), - 'total_fee' => $eventStatistics->getTotalFee() + $order->getTotalFee(), - 'version' => $eventStatistics->getVersion() + 1, - 'orders_created' => $eventStatistics->getOrdersCreated() + 1, - - ], - where: [ - 'event_id' => $order->getEventId(), - 'version' => $eventStatistics->getVersion(), - ] - ); - - if ($update === 0) { - throw new EventStatisticsVersionMismatchException( - 'Event statistics version mismatch. Expected version ' - . $eventStatistics->getVersion() . ' but got ' . $eventStatistics->getVersion() + 1 - . ' for event ' . $order->getEventId(), - ); - } - } - - /** - * @throws EventStatisticsVersionMismatchException - */ - private function updateEventDailyStats(OrderDomainObject $order): void - { - $eventDailyStatistic = $this->eventDailyStatisticRepository->findFirstWhere( - where: [ - 'event_id' => $order->getEventId(), - 'date' => (new Carbon($order->getCreatedAt()))->format('Y-m-d'), - ] - ); - - if ($eventDailyStatistic === null) { - $this->eventDailyStatisticRepository->create([ - 'event_id' => $order->getEventId(), - 'date' => (new Carbon($order->getCreatedAt()))->format('Y-m-d'), - '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(), - 'total_fee' => $order->getTotalFee(), - 'orders_created' => 1, - ]); - return; - } - - $update = $this->eventDailyStatisticRepository->updateWhere( - attributes: [ - '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(), - 'total_fee' => $eventDailyStatistic->getTotalFee() + $order->getTotalFee(), - 'version' => $eventDailyStatistic->getVersion() + 1, - 'orders_created' => $eventDailyStatistic->getOrdersCreated() + 1, - ], - where: [ - 'event_id' => $order->getEventId(), - 'date' => (new Carbon($order->getCreatedAt()))->format('Y-m-d'), - 'version' => $eventDailyStatistic->getVersion(), - ], - ); - - if ($update === 0) { - throw new EventStatisticsVersionMismatchException( - 'Event daily statistics version mismatch. Expected version ' - . $eventDailyStatistic->getVersion() . ' but got ' . $eventDailyStatistic->getVersion() + 1 - . ' for event ' . $order->getEventId(), - ); - } - } - - /** - * @param OrderDomainObject $order - * @param MoneyValue $amount - * @return void - */ - private function updateAggregateStatsWithRefund(OrderDomainObject $order, MoneyValue $amount): void - { - $eventStatistics = $this->eventStatisticsRepository->findFirstWhere([ - 'event_id' => $order->getEventId(), - ]); - - if (!$eventStatistics) { - throw new ResourceNotFoundException("Event statistics not found."); - } - - // Calculate the proportion of the refund to the total order amount - $refundProportion = $amount->toFloat() / $order->getTotalGross(); - - // Adjust the total_tax and total_fee based on the refund proportion - $adjustedTotalTax = $eventStatistics->getTotalTax() - ($order->getTotalTax() * $refundProportion); - $adjustedTotalFee = $eventStatistics->getTotalFee() - ($order->getTotalFee() * $refundProportion); - - // Update the event statistics with the new values - $this->eventStatisticsRepository->updateWhere( - attributes: [ - 'sales_total_gross' => $eventStatistics->getSalesTotalGross() - $amount->toFloat(), - 'total_refunded' => $eventStatistics->getTotalRefunded() + $amount->toFloat(), - 'total_tax' => $adjustedTotalTax, - 'total_fee' => $adjustedTotalFee, - ], - where: [ - 'event_id' => $order->getEventId(), - ] - ); - - $this->logger->info( - message: __('Event statistics updated for event :event_id with total refunded amount of :amount', [ - 'event_id' => $order->getEventId(), - 'amount' => $amount->toFloat(), - ]), - context: [ - 'event_id' => $order->getEventId(), - 'amount' => $amount->toFloat(), - 'original_total_gross' => $eventStatistics->getSalesTotalGross(), - 'original_total_refunded' => $eventStatistics->getTotalRefunded(), - 'original_total_tax' => $eventStatistics->getTotalTax(), - 'original_total_fee' => $eventStatistics->getTotalFee(), - 'tax_refunded' => $adjustedTotalTax, - 'fee_refunded' => $adjustedTotalFee, - ]); - } -} diff --git a/backend/app/Services/Domain/EventStatistics/Exception/EventStatisticsVersionMismatchException.php b/backend/app/Services/Domain/EventStatistics/Exception/EventStatisticsVersionMismatchException.php new file mode 100644 index 0000000000..c3bd972b9d --- /dev/null +++ b/backend/app/Services/Domain/EventStatistics/Exception/EventStatisticsVersionMismatchException.php @@ -0,0 +1,10 @@ +invoiceRepository->findLatestInvoiceForEvent($eventId); @@ -76,5 +76,4 @@ public function getLatestInvoiceNumber(int $eventId, EventSettingDomainObject $e return $prefix . $nextInvoiceNumber; } - } diff --git a/backend/app/Services/Domain/Mail/SendOrderDetailsService.php b/backend/app/Services/Domain/Mail/SendOrderDetailsService.php index 67626c5cd1..a1b139d650 100644 --- a/backend/app/Services/Domain/Mail/SendOrderDetailsService.php +++ b/backend/app/Services/Domain/Mail/SendOrderDetailsService.php @@ -16,6 +16,7 @@ use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Services\Domain\Attendee\SendAttendeeTicketService; +use HiEvents\Services\Domain\Email\MailBuilderService; use Illuminate\Mail\Mailer; class SendOrderDetailsService @@ -25,6 +26,7 @@ public function __construct( private readonly OrderRepositoryInterface $orderRepository, private readonly Mailer $mailer, private readonly SendAttendeeTicketService $sendAttendeeTicketService, + private readonly MailBuilderService $mailBuilderService, ) { } @@ -68,16 +70,18 @@ public function sendCustomerOrderSummary( ?InvoiceDomainObject $invoice = null ): void { + $mail = $this->mailBuilderService->buildOrderSummaryMail( + $order, + $event, + $eventSettings, + $organizer, + $invoice + ); + $this->mailer ->to($order->getEmail()) ->locale($order->getLocale()) - ->send(new OrderSummary( - order: $order, - event: $event, - organizer: $organizer, - eventSettings: $eventSettings, - invoice: $invoice, - )); + ->send($mail); } private function sendAttendeeTicketEmails(OrderDomainObject $order, EventDomainObject $event): void diff --git a/backend/app/Services/Domain/Order/OrderCancelService.php b/backend/app/Services/Domain/Order/OrderCancelService.php index a7e772929a..d1677ed502 100644 --- a/backend/app/Services/Domain/Order/OrderCancelService.php +++ b/backend/app/Services/Domain/Order/OrderCancelService.php @@ -17,6 +17,7 @@ use HiEvents\Services\Infrastructure\DomainEvents\DomainEventDispatcherService; use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; use HiEvents\Services\Infrastructure\DomainEvents\Events\OrderEvent; +use HiEvents\Services\Domain\EventStatistics\EventStatisticsCancellationService; use Illuminate\Contracts\Mail\Mailer; use Illuminate\Database\DatabaseManager; use Throwable; @@ -24,13 +25,14 @@ class OrderCancelService { public function __construct( - private readonly Mailer $mailer, - private readonly AttendeeRepositoryInterface $attendeeRepository, - private readonly EventRepositoryInterface $eventRepository, - private readonly OrderRepositoryInterface $orderRepository, - private readonly DatabaseManager $databaseManager, - private readonly ProductQuantityUpdateService $productQuantityService, - private readonly DomainEventDispatcherService $domainEventDispatcherService, + private readonly Mailer $mailer, + private readonly AttendeeRepositoryInterface $attendeeRepository, + private readonly EventRepositoryInterface $eventRepository, + private readonly OrderRepositoryInterface $orderRepository, + private readonly DatabaseManager $databaseManager, + private readonly ProductQuantityUpdateService $productQuantityService, + private readonly DomainEventDispatcherService $domainEventDispatcherService, + private readonly EventStatisticsCancellationService $eventStatisticsCancellationService, ) { } @@ -41,6 +43,9 @@ public function __construct( public function cancelOrder(OrderDomainObject $order): void { $this->databaseManager->transaction(function () use ($order) { + // Order of operations matters here. We must decrement the stats first. + $this->eventStatisticsCancellationService->decrementForCancelledOrder($order); + $this->adjustProductQuantities($order); $this->cancelAttendees($order); $this->updateOrderStatus($order); diff --git a/backend/app/Services/Domain/Order/OrderItemProcessingService.php b/backend/app/Services/Domain/Order/OrderItemProcessingService.php index f0b784b556..d047f78d1e 100644 --- a/backend/app/Services/Domain/Order/OrderItemProcessingService.php +++ b/backend/app/Services/Domain/Order/OrderItemProcessingService.php @@ -19,13 +19,13 @@ use Illuminate\Support\Collection; use Symfony\Component\Routing\Exception\ResourceNotFoundException; -readonly class OrderItemProcessingService +class OrderItemProcessingService { public function __construct( - private OrderRepositoryInterface $orderRepository, - private ProductRepositoryInterface $productRepository, - private TaxAndFeeCalculationService $taxCalculationService, - private ProductPriceService $productPriceService, + private readonly OrderRepositoryInterface $orderRepository, + private readonly ProductRepositoryInterface $productRepository, + private readonly TaxAndFeeCalculationService $taxCalculationService, + private readonly ProductPriceService $productPriceService, ) { } diff --git a/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentRequestDTO.php b/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentRequestDTO.php index b1f4f954ce..cc09890ae8 100644 --- a/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentRequestDTO.php +++ b/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentRequestDTO.php @@ -10,10 +10,11 @@ class CreatePaymentIntentRequestDTO extends BaseDTO { public function __construct( - public readonly MoneyValue $amount, - public readonly string $currencyCode, - public AccountDomainObject $account, - public OrderDomainObject $order, + public readonly MoneyValue $amount, + public readonly string $currencyCode, + public readonly AccountDomainObject $account, + public readonly OrderDomainObject $order, + public readonly ?string $stripeAccountId = null, ) { } diff --git a/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentResponseDTO.php b/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentResponseDTO.php index 4a68b74257..c556045a8f 100644 --- a/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentResponseDTO.php +++ b/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentResponseDTO.php @@ -2,14 +2,18 @@ namespace HiEvents\Services\Domain\Payment\Stripe\DTOs; +use HiEvents\DomainObjects\Enums\StripePlatform; + readonly class CreatePaymentIntentResponseDTO { public function __construct( - public ?string $paymentIntentId = null, - public ?string $clientSecret = null, - public ?string $accountId = null, - public ?string $error = null, - public int $applicationFeeAmount = 0, + public ?string $paymentIntentId = null, + public ?string $clientSecret = null, + public ?string $accountId = null, + public ?string $error = null, + public int $applicationFeeAmount = 0, + public ?StripePlatform $stripePlatform = null, + public ?string $publicKey = null, ) { } diff --git a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/AccountUpdateHandler.php b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/AccountUpdateHandler.php index b76395f341..98fbfaa1b8 100644 --- a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/AccountUpdateHandler.php +++ b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/AccountUpdateHandler.php @@ -2,52 +2,35 @@ namespace HiEvents\Services\Domain\Payment\Stripe\EventHandlers; -use HiEvents\Repository\Interfaces\AccountRepositoryInterface; -use Psr\Log\LoggerInterface; +use HiEvents\DomainObjects\AccountStripePlatformDomainObject; +use HiEvents\DomainObjects\Generated\AccountStripePlatformDomainObjectAbstract; +use HiEvents\Repository\Interfaces\AccountStripePlatformRepositoryInterface; +use HiEvents\Services\Domain\Payment\Stripe\StripeAccountSyncService; use Stripe\Account; use Symfony\Component\Routing\Exception\ResourceNotFoundException; -readonly class AccountUpdateHandler +class AccountUpdateHandler { public function __construct( - private LoggerInterface $logger, - private AccountRepositoryInterface $accountRepository, + private readonly AccountStripePlatformRepositoryInterface $accountStripePlatformRepository, + private readonly StripeAccountSyncService $stripeAccountSyncService, ) { } public function handleEvent(Account $stripeAccount): void { - $account = $this->accountRepository->findFirstWhere([ - 'stripe_account_id' => $stripeAccount->id, + /** @var AccountStripePlatformDomainObject $accountStripePlatform */ + $accountStripePlatform = $this->accountStripePlatformRepository->findFirstWhere([ + AccountStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id, ]); - if ($account === null) { + if ($accountStripePlatform === null) { throw new ResourceNotFoundException( - sprintf('Account with stripe account id %s not found', $stripeAccount->id) + sprintf('Account stripe platform with stripe account id %s not found', $stripeAccount->id) ); } - $isAccountSetupCompleted = $stripeAccount->charges_enabled && $stripeAccount->payouts_enabled; - - if ($account->getStripeConnectSetupComplete() === $isAccountSetupCompleted) { - return; - } - - $this->logger->info(sprintf( - 'Stripe connect account status change. Updating account %s with stripe account setup completed %s', - $stripeAccount->id, - $isAccountSetupCompleted ? 'true' : 'false' - ) - ); - - $this->accountRepository->updateWhere( - attributes: [ - 'stripe_connect_setup_complete' => $isAccountSetupCompleted, - ], - where: [ - 'stripe_account_id' => $stripeAccount->id, - ] - ); + $this->stripeAccountSyncService->syncStripeAccountStatus($accountStripePlatform, $stripeAccount); } } diff --git a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/ChargeRefundUpdatedHandler.php b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/ChargeRefundUpdatedHandler.php index 3aea1ba796..e21e4f7f2b 100644 --- a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/ChargeRefundUpdatedHandler.php +++ b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/ChargeRefundUpdatedHandler.php @@ -10,7 +10,7 @@ use HiEvents\Repository\Interfaces\OrderRefundRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\StripePaymentsRepositoryInterface; -use HiEvents\Services\Domain\EventStatistics\EventStatisticsUpdateService; +use HiEvents\Services\Domain\EventStatistics\EventStatisticsRefundService; use HiEvents\Services\Infrastructure\DomainEvents\DomainEventDispatcherService; use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; use HiEvents\Services\Infrastructure\DomainEvents\Events\OrderEvent; @@ -27,7 +27,7 @@ public function __construct( private readonly StripePaymentsRepositoryInterface $stripePaymentsRepository, private readonly Logger $logger, private readonly DatabaseManager $databaseManager, - private readonly EventStatisticsUpdateService $eventStatisticsUpdateService, + private readonly EventStatisticsRefundService $eventStatisticsRefundService, private readonly OrderRefundRepositoryInterface $orderRefundRepository, private readonly DomainEventDispatcherService $domainEventDispatcherService, ) @@ -99,7 +99,7 @@ private function amountAsFloat(int $amount, string $currency): float private function updateEventStatistics(OrderDomainObject $order, MoneyValue $amount): void { - $this->eventStatisticsUpdateService->updateEventStatsTotalRefunded($order, $amount); + $this->eventStatisticsRefundService->updateForRefund($order, $amount); } private function updateOrderRefundedAmount(int $orderId, float $refundedAmount): void diff --git a/backend/app/Services/Domain/Payment/Stripe/StripeAccountSyncService.php b/backend/app/Services/Domain/Payment/Stripe/StripeAccountSyncService.php new file mode 100644 index 0000000000..651f9f679b --- /dev/null +++ b/backend/app/Services/Domain/Payment/Stripe/StripeAccountSyncService.php @@ -0,0 +1,165 @@ +isStripeAccountComplete($stripeAccount); + $isCurrentlyComplete = $accountStripePlatform->getStripeSetupCompletedAt() !== null; + + // Only update if status has actually changed + if ($isCurrentlyComplete === $isAccountSetupCompleted) { + // Still update account details even if status hasn't changed + $this->updateAccountDetails($accountStripePlatform, $stripeAccount); + return; + } + + $this->logger->info(sprintf( + 'Stripe Connect account status change. Updating account stripe platform %s with stripe account setup completed %s', + $stripeAccount->id, + $isAccountSetupCompleted ? 'true' : 'false' + )); + + $this->updateAccountStatusAndDetails($accountStripePlatform, $stripeAccount, $isAccountSetupCompleted); + + // Also update account verification status if setup is complete + if ($isAccountSetupCompleted) { + $this->updateAccountVerificationStatus($accountStripePlatform); + } + } + + /** + * Force update account status when we know it should be complete + * (e.g., from GetStripeConnectAccountsHandler when Stripe says complete but DB doesn't) + */ + public function markAccountAsComplete( + AccountStripePlatformDomainObject $accountStripePlatform, + Account $stripeAccount + ): void { + $this->logger->info(sprintf( + 'Marking Stripe Connect account as complete for account stripe platform %s with Stripe account ID %s', + $accountStripePlatform->getId(), + $stripeAccount->id + )); + + $this->updateAccountStatusAndDetails($accountStripePlatform, $stripeAccount, true); + $this->updateAccountVerificationStatus($accountStripePlatform); + } + + public function isStripeAccountComplete(Account $stripeAccount): bool + { + return $stripeAccount->charges_enabled && $stripeAccount->payouts_enabled; + } + + private function updateAccountStatusAndDetails( + AccountStripePlatformDomainObject $accountStripePlatform, + Account $stripeAccount, + bool $isAccountSetupCompleted + ): void { + $this->accountStripePlatformRepository->updateWhere( + attributes: [ + AccountStripePlatformDomainObjectAbstract::STRIPE_SETUP_COMPLETED_AT => $isAccountSetupCompleted ? now() : null, + AccountStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_DETAILS => $this->buildAccountDetails($stripeAccount), + ], + where: [ + AccountStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id, + ] + ); + } + + private function updateAccountDetails( + AccountStripePlatformDomainObject $accountStripePlatform, + Account $stripeAccount + ): void { + $this->accountStripePlatformRepository->updateWhere( + attributes: [ + AccountStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_DETAILS => $this->buildAccountDetails($stripeAccount), + ], + where: [ + AccountStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id, + ] + ); + } + + private function buildAccountDetails(Account $stripeAccount): string + { + return json_encode([ + 'charges_enabled' => $stripeAccount->charges_enabled, + 'payouts_enabled' => $stripeAccount->payouts_enabled, + 'country' => $stripeAccount->country, + 'capabilities' => $stripeAccount->capabilities?->toArray(), + 'type' => $stripeAccount->type, + 'business_type' => $stripeAccount->business_type, + 'requirements' => [ + 'currently_due' => $stripeAccount->requirements?->currently_due ?? [], + 'eventually_due' => $stripeAccount->requirements?->eventually_due ?? [], + 'past_due' => $stripeAccount->requirements?->past_due ?? [], + 'pending_verification' => $stripeAccount->requirements?->pending_verification ?? [], + ], + ], JSON_THROW_ON_ERROR); + } + + public function createStripeAccountSetupUrl(Account $stripeAccount, StripeClient $stripeClient): ?string + { + try { + $accountLink = $stripeClient->accountLinks->create([ + 'account' => $stripeAccount->id, + 'refresh_url' => Url::getFrontEndUrlFromConfig(Url::STRIPE_CONNECT_REFRESH_URL, [ + 'is_refresh' => true, + ]), + 'return_url' => Url::getFrontEndUrlFromConfig(Url::STRIPE_CONNECT_RETURN_URL, [ + 'is_return' => true, + ]), + 'type' => 'account_onboarding', + ]); + + return $accountLink->url; + } catch (Throwable $e) { + $this->logger->error('Failed to create Stripe Connect Account Link', [ + 'stripe_account_id' => $stripeAccount->id, + 'error' => $e->getMessage(), + ]); + return null; + } + } + + private function updateAccountVerificationStatus(AccountStripePlatformDomainObject $accountStripePlatform): void + { + $account = $this->accountRepository->findById($accountStripePlatform->getAccountId()); + if (!$account->getIsManuallyVerified()) { + $this->accountRepository->updateWhere( + attributes: [ + 'is_manually_verified' => true, + ], + where: [ + 'id' => $accountStripePlatform->getAccountId(), + ] + ); + } + } +} \ No newline at end of file diff --git a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php index fc0e253660..abe204e346 100644 --- a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php +++ b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php @@ -18,7 +18,6 @@ class StripePaymentIntentCreationService { public function __construct( - private readonly StripeClient $stripeClient, private readonly LoggerInterface $logger, private readonly Repository $config, private readonly StripeCustomerRepositoryInterface $stripeCustomerRepository, @@ -31,13 +30,14 @@ public function __construct( /** * @throws CreatePaymentIntentFailedException */ - public function retrievePaymentIntentClientSecret( - string $paymentIntentId, - ?string $accountId = null, + public function retrievePaymentIntentClientSecretWithClient( + StripeClient $stripeClient, + string $paymentIntentId, + ?string $accountId = null, ): string { try { - return $this->stripeClient->paymentIntents->retrieve( + return $stripeClient->paymentIntents->retrieve( id: $paymentIntentId, opts: $accountId ? ['stripe_account' => $accountId] : [] )->client_secret; @@ -57,7 +57,10 @@ public function retrievePaymentIntentClientSecret( * @throws CreatePaymentIntentFailedException * @throws ApiErrorException|Throwable */ - public function createPaymentIntent(CreatePaymentIntentRequestDTO $paymentIntentDTO): CreatePaymentIntentResponseDTO + public function createPaymentIntentWithClient( + StripeClient $stripeClient, + CreatePaymentIntentRequestDTO $paymentIntentDTO + ): CreatePaymentIntentResponseDTO { try { $this->databaseManager->beginTransaction(); @@ -67,10 +70,10 @@ public function createPaymentIntent(CreatePaymentIntentRequestDTO $paymentIntent order: $paymentIntentDTO->order, )->toMinorUnit(); - $paymentIntent = $this->stripeClient->paymentIntents->create([ + $paymentIntent = $stripeClient->paymentIntents->create([ 'amount' => $paymentIntentDTO->amount->toMinorUnit(), 'currency' => $paymentIntentDTO->currencyCode, - 'customer' => $this->upsertStripeCustomer($paymentIntentDTO)->getStripeCustomerId(), + 'customer' => $this->upsertStripeCustomerWithClient($stripeClient, $paymentIntentDTO)->getStripeCustomerId(), 'metadata' => [ 'order_id' => $paymentIntentDTO->order->getId(), 'event_id' => $paymentIntentDTO->order->getEventId(), @@ -93,7 +96,7 @@ public function createPaymentIntent(CreatePaymentIntentRequestDTO $paymentIntent return new CreatePaymentIntentResponseDTO( paymentIntentId: $paymentIntent->id, clientSecret: $paymentIntent->client_secret, - accountId: $paymentIntentDTO->account->getStripeAccountId(), + accountId: $paymentIntentDTO->stripeAccountId, applicationFeeAmount: $applicationFee, ); } catch (ApiErrorException $exception) { @@ -123,7 +126,7 @@ private function getStripeAccountData(CreatePaymentIntentRequestDTO $paymentInte return []; } - if ($paymentIntentDTO->account->getStripeAccountId() === null) { + if ($paymentIntentDTO->stripeAccountId === null) { $this->logger->error( 'Stripe Connect account not found for the event organizer, payment intent creation failed. You will need to connect your Stripe account to receive payments.', @@ -136,22 +139,25 @@ private function getStripeAccountData(CreatePaymentIntentRequestDTO $paymentInte } return [ - 'stripe_account' => $paymentIntentDTO->account->getStripeAccountId() + 'stripe_account' => $paymentIntentDTO->stripeAccountId ]; } /** * @throws ApiErrorException|CreatePaymentIntentFailedException */ - private function upsertStripeCustomer(CreatePaymentIntentRequestDTO $paymentIntentDTO): StripeCustomerDomainObject + private function upsertStripeCustomerWithClient( + StripeClient $stripeClient, + CreatePaymentIntentRequestDTO $paymentIntentDTO + ): StripeCustomerDomainObject { $customer = $this->stripeCustomerRepository->findFirstWhere([ 'email' => $paymentIntentDTO->order->getEmail(), - 'stripe_account_id' => $paymentIntentDTO->account->getStripeAccountId(), + 'stripe_account_id' => $paymentIntentDTO->stripeAccountId, ]); if ($customer === null) { - $stripeCustomer = $this->stripeClient->customers->create( + $stripeCustomer = $stripeClient->customers->create( params: [ 'email' => $paymentIntentDTO->order->getEmail(), 'name' => $paymentIntentDTO->order->getFullName(), @@ -163,7 +169,7 @@ private function upsertStripeCustomer(CreatePaymentIntentRequestDTO $paymentInte 'name' => $stripeCustomer->name, 'email' => $stripeCustomer->email, 'stripe_customer_id' => $stripeCustomer->id, - 'stripe_account_id' => $paymentIntentDTO->account->getStripeAccountId(), + 'stripe_account_id' => $paymentIntentDTO->stripeAccountId, ]); } @@ -171,7 +177,7 @@ private function upsertStripeCustomer(CreatePaymentIntentRequestDTO $paymentInte return $customer; } - $stripeCustomer = $this->stripeClient->customers->update( + $stripeCustomer = $stripeClient->customers->update( id: $customer->getStripeCustomerId(), params: ['name' => $paymentIntentDTO->order->getFullName()], opts: $this->getStripeAccountData($paymentIntentDTO), diff --git a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php index 708e68bc70..23946a6764 100644 --- a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php +++ b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php @@ -11,11 +11,10 @@ use Stripe\Refund; use Stripe\StripeClient; -readonly class StripePaymentIntentRefundService +class StripePaymentIntentRefundService { public function __construct( - private StripeClient $stripeClient, - private Repository $config, + private readonly Repository $config, ) { } @@ -28,9 +27,10 @@ public function __construct( public function refundPayment( MoneyValue $amount, StripePaymentDomainObject $payment, + StripeClient $stripeClient, ): Refund { - return $this->stripeClient->refunds->create( + return $stripeClient->refunds->create( params: [ 'payment_intent' => $payment->getPaymentIntentId(), 'amount' => $amount->toMinorUnit() diff --git a/backend/app/Services/Domain/Product/ProductFilterService.php b/backend/app/Services/Domain/Product/ProductFilterService.php index 099cdb7845..f8dcd8f1b3 100644 --- a/backend/app/Services/Domain/Product/ProductFilterService.php +++ b/backend/app/Services/Domain/Product/ProductFilterService.php @@ -162,12 +162,15 @@ private function filterProduct( 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)); + // If the product is free of charge, we don't charge service fees or taxes + if (!$price->isFree()) { + $taxAndFees = $this->taxCalculationService + ->calculateTaxAndFeesForProductPrice($product, $price); + + $price + ->setTaxTotal(Currency::round($taxAndFees->taxTotal)) + ->setFeeTotal(Currency::round($taxAndFees->feeTotal)); + } $price->setIsAvailable($this->getPriceAvailability($price, $product)); } diff --git a/backend/app/Services/Domain/Tax/TaxAndFeeCalculationService.php b/backend/app/Services/Domain/Tax/TaxAndFeeCalculationService.php index f3343269bf..ac9c11da4f 100644 --- a/backend/app/Services/Domain/Tax/TaxAndFeeCalculationService.php +++ b/backend/app/Services/Domain/Tax/TaxAndFeeCalculationService.php @@ -3,9 +3,9 @@ namespace HiEvents\Services\Domain\Tax; use HiEvents\DomainObjects\Enums\TaxCalculationType; -use HiEvents\DomainObjects\TaxAndFeesDomainObject; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; +use HiEvents\DomainObjects\TaxAndFeesDomainObject; use HiEvents\Services\Domain\Tax\DTO\TaxCalculationResponse; use InvalidArgumentException; @@ -49,6 +49,13 @@ public function calculateTaxAndFeesForProduct( private function calculateFee(TaxAndFeesDomainObject $taxOrFee, float $price, int $quantity): float { + // We do not charge a tax or fee on items which are free of charge + if ($price === 0.00) { + $this->taxRollupService->addToRollUp($taxOrFee, 0); + + return 0.00; + } + $amount = match ($taxOrFee->getCalculationType()) { TaxCalculationType::FIXED->name => $taxOrFee->getRate(), TaxCalculationType::PERCENTAGE->name => ($price * $taxOrFee->getRate()) / 100, diff --git a/backend/app/Services/Infrastructure/Email/LiquidTemplateRenderer.php b/backend/app/Services/Infrastructure/Email/LiquidTemplateRenderer.php new file mode 100644 index 0000000000..813f702845 --- /dev/null +++ b/backend/app/Services/Infrastructure/Email/LiquidTemplateRenderer.php @@ -0,0 +1,222 @@ +liquid = new Template(); + $this->liquid->parse(''); // Initialize + } + + public function render(string $template, array $context): string + { + try { + $this->liquid->parse($template); + return $this->liquid->render($context); + } catch (Exception $e) { + throw new RuntimeException('Failed to render template: ' . $e->getMessage(), 0, $e); + } + } + + public function validate(string $template): bool + { + try { + $this->liquid->parse($template); + return true; + } catch (ParseException $e) { + return false; + } + } + + public function getValidationErrors(string $template): ?string + { + try { + $this->liquid->parse($template); + return null; + } catch (ParseException $e) { + return $e->getMessage(); + } + } + + public function getAvailableTokens(EmailTemplateType $type): array + { + $commonTokens = [ + [ + 'token' => '{{ event.title }}', + 'description' => __('The name of the event'), + 'example' => 'Summer Music Festival 2024', + ], + [ + 'token' => '{{ event.date }}', + 'description' => __('The event start date'), + 'example' => 'January 15, 2024', + ], + [ + 'token' => '{{ event.time }}', + 'description' => __('The event start time'), + 'example' => '7:00 PM', + ], + [ + 'token' => '{{ event.end_date }}', + 'description' => __('The event end date'), + 'example' => 'January 16, 2024', + ], + [ + 'token' => '{{ event.end_time }}', + 'description' => __('The event end time'), + 'example' => '11:00 PM', + ], + [ + 'token' => '{{ event.full_address }}', + 'description' => __('The full event address'), + 'example' => '3 Arena, North Wall Quay, Dublin 1, D01 T0X4, Ireland', + ], + [ + 'token' => '{{ event.location_details.venue_name }}', + 'description' => __('The event venue name'), + 'example' => '3 Arena', + ], + [ + 'token' => '{{ event.location_details.address_line_1 }}', + 'description' => __('The venue address line 1'), + 'example' => 'North Wall Quay', + ], + [ + 'token' => '{{ event.location_details.address_line_2 }}', + 'description' => __('The venue address line 2'), + 'example' => '', + ], + [ + 'token' => '{{ event.location_details.city }}', + 'description' => __('The venue city'), + 'example' => 'Dublin', + ], + [ + 'token' => '{{ event.location_details.state_or_region }}', + 'description' => __('The venue state or region'), + 'example' => 'Dublin 1', + ], + [ + 'token' => '{{ event.location_details.zip_or_postal_code }}', + 'description' => __('The venue ZIP or postal code'), + 'example' => 'D01 T0X4', + ], + [ + 'token' => '{{ event.location_details.country }}', + 'description' => __('The venue country code'), + 'example' => 'IE', + ], + [ + 'token' => '{{ event.description }}', + 'description' => __('The event description'), + 'example' => 'Join us for an amazing event!', + ], + [ + 'token' => '{{ organizer.name }}', + 'description' => __('The organizer\'s name'), + 'example' => 'ACME Events Inc.', + ], + [ + 'token' => '{{ organizer.email }}', + 'description' => __('The organizer\'s email'), + 'example' => 'contact@acme-events.com', + ], + [ + 'token' => '{{ settings.support_email }}', + 'description' => __('The support email address'), + 'example' => 'support@acme-events.com', + ], + [ + 'token' => '{{ settings.offline_payment_instructions }}', + 'description' => __('Instructions for offline payment'), + 'example' => 'Please transfer payment to account...', + ], + [ + 'token' => '{{ settings.post_checkout_message }}', + 'description' => __('Message shown after checkout'), + 'example' => 'Thank you for your purchase!', + ], + ]; + + $orderTokens = [ + [ + 'token' => '{{ order.url }}', + 'description' => __('Link to view the order summary'), + 'example' => 'https://example.com/order/ABC123', + ], + [ + 'token' => '{{ order.number }}', + 'description' => __('The order reference number'), + 'example' => 'ORD-2024-001234', + ], + [ + 'token' => '{{ order.total }}', + 'description' => __('The total order amount'), + 'example' => '$150.00', + ], + [ + 'token' => '{{ order.date }}', + 'description' => __('The order date'), + 'example' => 'January 10, 2024', + ], + [ + 'token' => '{{ order.first_name }}', + 'description' => __('The first name of the person who placed the order'), + 'example' => 'John', + ], + [ + 'token' => '{{ order.last_name }}', + 'description' => __('The last name of the person who placed the order'), + 'example' => 'Smith', + ], + [ + 'token' => '{{ order.email }}', + 'description' => __('The email of the person who placed the order'), + 'example' => 'john@example.com', + ], + ]; + + $attendeeTokens = [ + [ + 'token' => '{{ attendee.name }}', + 'description' => __('The attendee\'s full name'), + 'example' => 'John Smith', + ], + [ + 'token' => '{{ attendee.email }}', + 'description' => __('The attendee\'s email'), + 'example' => 'john@example.com', + ], + [ + 'token' => '{{ ticket.name }}', + 'description' => __('The ticket type name'), + 'example' => 'VIP Pass', + ], + [ + 'token' => '{{ ticket.price }}', + 'description' => __('The ticket price'), + 'example' => '$75.00', + ], + [ + 'token' => '{{ ticket.url }}', + 'description' => __('Link to view/download the ticket'), + 'example' => 'https://example.com/ticket/XYZ789', + ], + ]; + + return match ($type) { + EmailTemplateType::ORDER_CONFIRMATION => array_merge($commonTokens, $orderTokens), + EmailTemplateType::ATTENDEE_TICKET => array_merge($commonTokens, $orderTokens, $attendeeTokens), + }; + } +} diff --git a/backend/app/Services/Infrastructure/Stripe/StripeClientFactory.php b/backend/app/Services/Infrastructure/Stripe/StripeClientFactory.php new file mode 100644 index 0000000000..5883b91af1 --- /dev/null +++ b/backend/app/Services/Infrastructure/Stripe/StripeClientFactory.php @@ -0,0 +1,32 @@ +configurationService->getSecretKey($platform); + + if (empty($secretKey)) { + $platformName = $platform?->value ?: 'default'; + throw new StripeClientConfigurationException( + __('Stripe secret key not configured for platform: :platform', ['platform' => $platformName]) + ); + } + + return new StripeClient($secretKey); + } +} diff --git a/backend/app/Services/Infrastructure/Stripe/StripeConfigurationService.php b/backend/app/Services/Infrastructure/Stripe/StripeConfigurationService.php new file mode 100644 index 0000000000..5ac01b36a5 --- /dev/null +++ b/backend/app/Services/Infrastructure/Stripe/StripeConfigurationService.php @@ -0,0 +1,52 @@ + config('services.stripe.ca_secret_key', config('services.stripe.secret_key')), + StripePlatform::IRELAND => config('services.stripe.ie_secret_key', config('services.stripe.secret_key')), + default => config('services.stripe.secret_key'), + }; + } + + public function getPublicKey(?StripePlatform $platform = null): ?string + { + return match ($platform) { + StripePlatform::CANADA => config('services.stripe.ca_public_key', config('services.stripe.public_key')), + StripePlatform::IRELAND => config('services.stripe.ie_public_key', config('services.stripe.public_key')), + default => config('services.stripe.public_key'), + }; + } + + public function getPrimaryPlatform(): ?StripePlatform + { + $platformString = config('services.stripe.primary_platform'); + return StripePlatform::fromString($platformString); + } + + public function getAllWebhookSecrets(): array + { + $secrets = array_filter([ + 'default' => config('services.stripe.webhook_secret'), + StripePlatform::CANADA->value => config('services.stripe.ca_webhook_secret'), + StripePlatform::IRELAND->value => config('services.stripe.ie_webhook_secret'), + ]); + + // order by primary platform first + $primary = $this->getPrimaryPlatform()?->value; + + if ($primary && isset($secrets[$primary])) { + $primarySecret = [$primary => $secrets[$primary]]; + unset($secrets[$primary]); + return $primarySecret + $secrets; + } + + return $secrets; + } +} diff --git a/backend/app/Services/Infrastructure/Utlitiy/Retry/Retrier.php b/backend/app/Services/Infrastructure/Utlitiy/Retry/Retrier.php new file mode 100644 index 0000000000..fd24aeaae0 --- /dev/null +++ b/backend/app/Services/Infrastructure/Utlitiy/Retry/Retrier.php @@ -0,0 +1,60 @@ +|array> $retryOn Exceptions to retry + * @return mixed + * @throws Throwable + */ + public function retry( + callable $callableAction, + int $maxAttempts = 3, + int $baseDelayMs = 25, + int $maxDelayMs = 250, + ?callable $onFailure = null, + array $retryOn = [Throwable::class], + ): mixed + { + for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { + try { + return $callableAction($attempt); + } catch (Throwable $e) { + $isRetryable = false; + foreach ($retryOn as $cls) { + if ($e instanceof $cls) { + $isRetryable = true; + break; + } + } + + $isLast = ($attempt === $maxAttempts); + + if (!$isRetryable || $isLast) { + if ($onFailure !== null) { + $onFailure($attempt, $e); + } + throw $e; + } + + // Exponential backoff with cap + $delay = min($maxDelayMs, $baseDelayMs * (1 << max(0, $attempt - 1))); + usleep($delay * 1000); + } + } + + // Unreachable + return null; + } +} diff --git a/backend/app/Validators/CompleteOrderValidator.php b/backend/app/Validators/CompleteOrderValidator.php index 948a1b1b49..af67340074 100644 --- a/backend/app/Validators/CompleteOrderValidator.php +++ b/backend/app/Validators/CompleteOrderValidator.php @@ -84,8 +84,10 @@ public function rules(): array public function messages(): array { return [ - 'order.first_name' => __('First name is required'), - 'order.last_name' => __('Last name is required'), + 'order.first_name.max' => 'First name must be under 40 characters', + 'order.last_name.max' => 'Last name must be under 40 characters', + 'order.first_name.required' => __('First name is required'), + 'order.last_name.required' => __('Last name is required'), 'order.email' => __('A valid email is required'), 'order.address.address_line_1.required' => __('Address line 1 is required'), 'order.address.city.required' => __('City is required'), diff --git a/backend/composer.json b/backend/composer.json index d58433eaa1..050ef94819 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -18,6 +18,7 @@ "laravel/tinker": "^2.8", "laravel/vapor-core": "^2.37", "league/flysystem-aws-s3-v3": "^3.0", + "liquid/liquid": "^1.4", "maatwebsite/excel": "^3.1", "nette/php-generator": "^4.0", "php-open-source-saver/jwt-auth": "^2.1", diff --git a/backend/composer.lock b/backend/composer.lock index 91afb3fc6f..312ed20758 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f88ca086291d34df80361dea877fe16d", + "content-hash": "618ef77776fbdae2c77bb52d3951ba1b", "packages": [ { "name": "amphp/amp", @@ -4301,6 +4301,75 @@ ], "time": "2024-12-08T08:18:47+00:00" }, + { + "name": "liquid/liquid", + "version": "1.4.44", + "source": { + "type": "git", + "url": "https://github.com/kalimatas/php-liquid.git", + "reference": "1b74ad64001d21f27223849624f7aab0c379815a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/kalimatas/php-liquid/zipball/1b74ad64001d21f27223849624f7aab0c379815a", + "reference": "1b74ad64001d21f27223849624f7aab0c379815a", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "ergebnis/composer-normalize": ">=2.47", + "friendsofphp/php-cs-fixer": "^3.75", + "infection/infection": ">=0.17.6", + "php-coveralls/php-coveralls": "^2.8", + "phpunit/phpunit": "^9.6.23" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Liquid\\": "src/Liquid" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guz Alexander", + "email": "kalimatas@gmail.com", + "homepage": "http://guzalexander.com" + }, + { + "name": "Harald Hanek" + }, + { + "name": "Mateo Murphy" + }, + { + "name": "Alexey Kopytko", + "email": "alexey@kopytko.com", + "homepage": "https://www.alexeykopytko.com/" + } + ], + "description": "Liquid template engine for PHP", + "homepage": "https://github.com/kalimatas/php-liquid", + "keywords": [ + "liquid", + "template" + ], + "support": { + "issues": "https://github.com/kalimatas/php-liquid/issues", + "source": "https://github.com/kalimatas/php-liquid/tree/1.4.44" + }, + "time": "2025-05-31T10:45:02+00:00" + }, { "name": "maatwebsite/excel", "version": "3.1.67", diff --git a/backend/config/services.php b/backend/config/services.php index f8faf1c875..44f123a1e7 100644 --- a/backend/config/services.php +++ b/backend/config/services.php @@ -35,6 +35,19 @@ 'secret_key' => env('STRIPE_SECRET_KEY'), 'public_key' => env('STRIPE_PUBLIC_KEY'), 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), + + // Canadian platform (Optional) + 'ca_secret_key' => env('STRIPE_CA_SECRET_KEY', env('STRIPE_SECRET_KEY')), + 'ca_public_key' => env('STRIPE_CA_PUBLIC_KEY', env('STRIPE_PUBLIC_KEY')), + 'ca_webhook_secret' => env('STRIPE_CA_WEBHOOK_SECRET', env('STRIPE_WEBHOOK_SECRET')), + + // Irish platform (Optional) + 'ie_secret_key' => env('STRIPE_IE_SECRET_KEY', env('STRIPE_SECRET_KEY')), + 'ie_public_key' => env('STRIPE_IE_PUBLIC_KEY', env('STRIPE_PUBLIC_KEY')), + 'ie_webhook_secret' => env('STRIPE_IE_WEBHOOK_SECRET', env('STRIPE_WEBHOOK_SECRET')), + + // Primary platform for new organizers + 'primary_platform' => env('STRIPE_PRIMARY_PLATFORM'), ], 'open_exchange_rates' => [ 'app_id' => env('OPEN_EXCHANGE_RATES_APP_ID'), diff --git a/backend/database/migrations/2025_08_27_192754_add_statistics_decremented_at_to_orders_table.php b/backend/database/migrations/2025_08_27_192754_add_statistics_decremented_at_to_orders_table.php new file mode 100644 index 0000000000..053d9aa767 --- /dev/null +++ b/backend/database/migrations/2025_08_27_192754_add_statistics_decremented_at_to_orders_table.php @@ -0,0 +1,28 @@ +timestamp('statistics_decremented_at')->nullable()->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('orders', function (Blueprint $table) { + $table->dropColumn('statistics_decremented_at'); + }); + } +}; diff --git a/backend/database/migrations/2025_08_27_193114_add_orders_cancelled_to_event_statistics_tables.php b/backend/database/migrations/2025_08_27_193114_add_orders_cancelled_to_event_statistics_tables.php new file mode 100644 index 0000000000..0530d2613f --- /dev/null +++ b/backend/database/migrations/2025_08_27_193114_add_orders_cancelled_to_event_statistics_tables.php @@ -0,0 +1,44 @@ +unsignedInteger('orders_cancelled')->default(0); + }); + } + + if (!Schema::hasColumn('event_daily_statistics', 'orders_cancelled')) { + Schema::table('event_daily_statistics', function (Blueprint $table) { + $table->unsignedInteger('orders_cancelled')->default(0); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('event_statistics', 'orders_cancelled')) { + Schema::table('event_statistics', function (Blueprint $table) { + $table->dropColumn('orders_cancelled'); + }); + } + + if (Schema::hasColumn('event_daily_statistics', 'orders_cancelled')) { + Schema::table('event_daily_statistics', function (Blueprint $table) { + $table->dropColumn('orders_cancelled'); + }); + } + } +}; diff --git a/backend/database/migrations/2025_08_28_100704_create_email_templates_table.php b/backend/database/migrations/2025_08_28_100704_create_email_templates_table.php new file mode 100644 index 0000000000..9fd18a914b --- /dev/null +++ b/backend/database/migrations/2025_08_28_100704_create_email_templates_table.php @@ -0,0 +1,47 @@ +id(); + $table->bigInteger('account_id')->unsigned(); + $table->bigInteger('organizer_id')->unsigned()->nullable(); + $table->bigInteger('event_id')->unsigned()->nullable(); + $table->string('template_type', 50); // order_confirmation, attendee_ticket + $table->string('subject', 255); + $table->text('body'); + $table->json('cta')->nullable(); // {label, url_token} + $table->string('engine', 20)->default('liquid'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + + // Foreign keys + $table->foreign('account_id')->references('id')->on('accounts')->onDelete('cascade'); + $table->foreign('organizer_id')->references('id')->on('organizers')->onDelete('cascade'); + $table->foreign('event_id')->references('id')->on('events')->onDelete('cascade'); + + // Indexes + $table->index(['account_id', 'template_type', 'is_active']); + $table->index(['event_id', 'template_type']); + $table->index(['organizer_id', 'template_type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('email_templates'); + } +}; diff --git a/backend/database/migrations/2025_09_04_071235_add_ticket_design_settings_to_event_settings.php b/backend/database/migrations/2025_09_04_071235_add_ticket_design_settings_to_event_settings.php new file mode 100644 index 0000000000..b7ab6b1008 --- /dev/null +++ b/backend/database/migrations/2025_09_04_071235_add_ticket_design_settings_to_event_settings.php @@ -0,0 +1,28 @@ +jsonb('ticket_design_settings')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('event_settings', function (Blueprint $table) { + $table->dropColumn('ticket_design_settings'); + }); + } +}; diff --git a/backend/database/migrations/2025_10_02_084321_create_account_stripe_platforms_table.php b/backend/database/migrations/2025_10_02_084321_create_account_stripe_platforms_table.php new file mode 100644 index 0000000000..9532032cc2 --- /dev/null +++ b/backend/database/migrations/2025_10_02_084321_create_account_stripe_platforms_table.php @@ -0,0 +1,84 @@ +id(); + $table->unsignedBigInteger('account_id'); + $table->string('stripe_connect_account_type')->nullable(); + $table->string('stripe_connect_platform', 2)->nullable(); + $table->string('stripe_account_id')->nullable()->unique(); + $table->timestamp('stripe_setup_completed_at')->nullable(); + $table->jsonb('stripe_account_details')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->foreign('account_id')->references('id')->on('accounts')->onDelete('cascade'); + $table->index(['account_id', 'stripe_connect_platform']); + $table->index('stripe_connect_platform'); + }); + + // Migrate existing data from accounts table to the new table + // For Hi.Events installations, set platform to 'ca' (Canada), otherwise leave as NULL for open-source + $isHiEvents = config('app.is_hi_events', false); + $platform = $isHiEvents ? "'ca'" : 'NULL'; + + DB::statement(" + INSERT INTO account_stripe_platforms ( + account_id, + stripe_connect_account_type, + stripe_connect_platform, + stripe_account_id, + stripe_setup_completed_at, + created_at, + updated_at + ) + SELECT + id, + stripe_connect_account_type, + {$platform}, + stripe_account_id, + CASE + WHEN stripe_connect_setup_complete = true THEN NOW() + ELSE NULL + END, + NOW(), + NOW() + FROM accounts + WHERE stripe_account_id IS NOT NULL + "); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Migrate data back to accounts table before dropping + DB::statement(' + UPDATE accounts a + SET + stripe_connect_account_type = asp.stripe_connect_account_type, + stripe_platform = asp.stripe_connect_platform, + stripe_account_id = asp.stripe_account_id, + stripe_connect_setup_complete = CASE + WHEN asp.stripe_setup_completed_at IS NOT NULL THEN true + ELSE false + END + FROM account_stripe_platforms asp + WHERE a.id = asp.account_id + '); + + Schema::dropIfExists('account_stripe_platforms'); + } +}; diff --git a/backend/database/migrations/2025_10_02_103604_add_platform_to_stripe_payments_table.php b/backend/database/migrations/2025_10_02_103604_add_platform_to_stripe_payments_table.php new file mode 100644 index 0000000000..74455bbd84 --- /dev/null +++ b/backend/database/migrations/2025_10_02_103604_add_platform_to_stripe_payments_table.php @@ -0,0 +1,39 @@ +string('stripe_platform', 2)->nullable()->after('connected_account_id'); + $table->index('stripe_platform'); + }); + + // Backfill existing stripe payments with 'ca' platform for Hi.Events cloud installations + if (config('app.is_hi_events')) { + DB::table('stripe_payments') + ->whereNull('stripe_platform') + ->update(['stripe_platform' => 'ca']); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('stripe_payments', function (Blueprint $table) { + $table->dropIndex(['stripe_platform']); + $table->dropColumn('stripe_platform'); + }); + } +}; diff --git a/backend/database/migrations/2025_10_02_104751_backfill_stripe_payments_platform.php b/backend/database/migrations/2025_10_02_104751_backfill_stripe_payments_platform.php new file mode 100644 index 0000000000..2918a5084f --- /dev/null +++ b/backend/database/migrations/2025_10_02_104751_backfill_stripe_payments_platform.php @@ -0,0 +1,34 @@ +whereNull('stripe_platform') + ->update(['stripe_platform' => 'ca']); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Revert the backfill for Hi.Events cloud installations + if (config('app.is_hi_events')) { + DB::table('stripe_payments') + ->where('stripe_platform', 'ca') + ->update(['stripe_platform' => null]); + } + } +}; diff --git a/backend/lang/tr.json b/backend/lang/tr.json new file mode 100644 index 0000000000..7ae6168c8d --- /dev/null +++ b/backend/lang/tr.json @@ -0,0 +1,509 @@ +{ + "Older First": "Önce Eskiler", + "Newer First": "Önce Yeniler", + "Recently Updated First": "Önce Son Güncellenenler", + "Recently Updated Last": "Son Güncellenenler Sonda", + "First Name A-Z": "Ad A-Z", + "First Name Z-A": "Ad Z-A", + "Last Name A-Z": "Soyad A-Z", + "Last Name Z-A": "Soyad Z-A", + "Status A-Z": "Durum A-Z", + "Status Z-A": "Durum Z-A", + "Closest start date": "En yakın başlangıç tarihi", + "Furthest start date": "En uzak başlangıç tarihi", + "Closest end date": "En yakın bitiş tarihi", + "Furthest end date": "En uzak bitiş tarihi", + "Newest first": "Önce en yeniler", + "Oldest first": "Önce en eskiler", + "Recently Updated": "Son Güncellenenler", + "Least Recently Updated": "En Az Son Güncellenenler", + "Oldest First": "Önce En Eskiler", + "Newest First": "Önce En Yeniler", + "Buyer Name A-Z": "Alıcı Adı A-Z", + "Buyer Name Z-A": "Alıcı Adı Z-A", + "Amount Ascending": "Tutar Artan", + "Amount Descending": "Tutar Azalan", + "Buyer Email A-Z": "Alıcı E-postası A-Z", + "Buyer Email Z-A": "Alıcı E-postası Z-A", + "Order # Ascending": "Sipariş No. Artan", + "Order # Descending": "Sipariş No. Azalan", + "Code Name A-Z": "Kod Adı A-Z", + "Code Name Z-A": "Kod Adı Z-A", + "Usage Count Ascending": "Kullanım Sayısı Artan", + "Usage Count Descending": "Kullanım Sayısı Azalan", + "Homepage order": "Ana sayfa sırası", + "Title A-Z": "Başlık A-Z", + "Title Z-A": "Başlık Z-A", + "Sale start date closest": "Satış başlangıç tarihi en yakın", + "Sale start date furthest": "Satış başlangıç tarihi en uzak", + "Sale end date closest": "Satış bitiş tarihi en yakın", + "Sale end date furthest": "Satış bitiş tarihi en uzak", + "Account registration is disabled": "Hesap kaydı devre dışı", + "The invitation has expired": "Davet süresiz dolmuş", + "The invitation is invalid": "Davet geçersiz", + "Invitation valid, but user not found": "Davet geçerli, ancak kullanıcı bulunamadı", + "No user found for this invitation. The invitation may have been revoked.": "Bu davet için kullanıcı bulunamadı. Davet iptal edilmiş olabilir.", + "Logout Successful": "Çıkış Başarılı", + "Your password has been reset. Please login with your new password.": "Şifreniz sıfırlandı. Lütfen yeni şifrenizle giriş yapın.", + "No account ID found in token": "Token'da hesap kimliği bulunamadı", + "Event with ID :eventId is not live and user is not authenticated": ":eventId kimlikli etkinlik canlı değil ve kullanıcı kimlik doğrulaması yapılmamış", + "Sorry, we could not verify your session. Please restart your order.": "Üzgünüz, oturumunuzu doğrulayamadık. Lütfen siparişinizi yeniden başlatın.", + "The email confirmation link has expired. Please request a new one.": "E-posta onay bağlantısının süresi dolmuş. Lütfen yeni bir tane isteyin.", + "The email confirmation link is invalid.": "E-posta onay bağlantısı geçersiz.", + "No invitation found for this user.": "Bu kullanıcı için davet bulunamadı.", + "User status is not Invited": "Kullanıcı durumu Davet Edildi değil", + "Email is required": "E-posta gerekli", + "Email must be a valid email address": "E-posta geçerli bir e-posta adresi olmalıdır", + "First name is required": "Ad gerekli", + "Last name is required": "Soyad gerekli", + "Ticket is required": "Bilet gerekli", + "Ticket price is required": "Bilet fiyatı gerekli", + "Please enter a valid hex color code. In the format #000000 or #000.": "Lütfen geçerli bir hex renk kodu girin. #000000 veya #000 formatında.", + "The maximum timeout is 2 hours.": "Maksimum zaman aşımı 2 saattir.", + "The address line 1 field is required": "Adres satırı 1 alanı gerekli", + "The city field is required": "Şehir alanı gerekli", + "The zip or postal code field is required": "Posta kodu alanı gerekli", + "The country field is required": "Ülke alanı gerekli", + "The country field should be a 2 character ISO 3166 code": "Ülke alanı 2 karakterli ISO 3166 kodu olmalıdır", + "Please select at least one ticket.": "Lütfen en az bir bilet seçin.", + "The sale end date must be after the sale start date.": "Satış bitiş tarihi, satış başlangıç tarihinden sonra olmalıdır.", + "The sale end date must be a valid date.": "Satış bitiş tarihi geçerli bir tarih olmalıdır.", + "The sale start date must be after the ticket sale start date.": "Satış başlangıç tarihi, bilet satış başlangıç tarihinden sonra olmalıdır.", + "Welcome to :app_name! Please confirm your email address": ":app_name'e hoş geldiniz! Lütfen e-posta adresinizi onaylayın", + "🎟️ Your Ticket for :event": "🎟️ :event için Biletiniz", + "Your order wasn't successful": "Siparişiniz başarılı olmadı", + "We were unable to process your order": "Siparişinizi işleyemedik", + "New order for :amount for :event 🎉": ":event için :amount tutarında yeni sipariş 🎉", + "New order for :event 🎉": ":event için yeni sipariş 🎉", + "Current account ID is not set": "Geçerli hesap kimliği ayarlanmamış", + "User not found in this account": "Bu hesapta kullanıcı bulunamadı", + "User not found": "Kullanıcı bulunamadı", + "Username or Password are incorrect": "Kullanıcı adı veya Şifre yanlış", + "Account not found": "Hesap bulunamadı", + "Attempt to log in to a non-active account": "Aktif olmayan bir hesaba giriş denemesi", + "User account is not active": "Kullanıcı hesabı aktif değil", + "Invalid reset token": "Geçersiz sıfırlama token'ı", + "Reset token has expired": "Sıfırlama token'ının süresi dolmuş", + "Event daily statistics updated for event :event_id with total refunded amount of :amount": ":event_id etkinliği için günlük istatistikler güncellendi, toplam iade tutarı :amount", + "Event statistics updated for event :event_id with total refunded amount of :amount": ":event_id etkinliği için istatistikler güncellendi, toplam iade tutarı :amount", + "This promo code is invalid": "Bu promosyon kodu geçersiz", + "You haven't selected any tickets": "Herhangi bir bilet seçmediniz", + "The maximum number of tickets available for :tickets is :max": ":tickets için mevcut maksimum bilet sayısı :max", + "You must order at least :min tickets for :ticket": ":ticket için en az :min bilet sipariş etmelisiniz", + "The minimum amount is :price": "Minimum tutar :price", + "The ticket :ticket is sold out": ":ticket bileti tükendi", + ":field must be specified": ":field belirtilmelidir", + "Invalid price ID": "Geçersiz fiyat kimliği", + "The maximum number of tickets available for :ticket is :max": ":ticket için mevcut maksimum bilet sayısı :max", + "Ticket with id :id not found": ":id kimlikli bilet bulunamadı", + "Failed to refund stripe charge": "Stripe ücreti iade edilemedi", + "Payment was successful, but order has expired. Order: :id": "Ödeme başarılı oldu, ancak sipariş süresi doldu. Sipariş: :id", + "Order is not awaiting payment. Order: :id": "Sipariş ödeme beklemiyor. Sipariş: :id", + "There was an error communicating with the payment provider. Please try again later.": "Ödeme sağlayıcısıyla iletişimde bir hata oluştu. Lütfen daha sonra tekrar deneyin.", + "Stripe Connect account not found for the event organizer": "Etkinlik organizatörü için Stripe Connect hesabı bulunamadı", + "Cannot Refund: Stripe connect account not found and saas_mode_enabled is enabled": "İade Edilemiyor: Stripe connect hesabı bulunamadı ve saas_mode_enabled etkin", + "Invalid calculation type": "Geçersiz hesaplama türü", + "One or more tax IDs are invalid": "Bir veya daha fazla vergi kimliği geçersiz", + "Invalid ticket ids: :ids": "Geçersiz bilet kimlikleri: :ids", + "Cannot delete ticket price with id :id because it has sales": ":id kimlikli bilet fiyatı satışları olduğu için silinemez", + "Order has no order items": "Siparişin sipariş öğeleri yok", + "There is already an account associated with this email. Please log in instead.": "Bu e-posta ile ilişkili bir hesap zaten var. Lütfen bunun yerine giriş yapın.", + "Stripe Connect Account creation is only available in Saas Mode.": "Stripe Connect Hesabı oluşturma sadece Saas Modunda kullanılabilir.", + "There are issues with creating or fetching the Stripe Connect Account. Please try again.": "Stripe Connect Hesabı oluşturma veya getirmede sorunlar var. Lütfen tekrar deneyin.", + "There are issues with creating the Stripe Connect Account Link. Please try again.": "Stripe Connect Hesap Bağlantısı oluşturmada sorunlar var. Lütfen tekrar deneyin.", + "Cannot check in attendee as they are not active.": "Katılımcı aktif olmadığından check in yapılamıyor.", + "in": "içinde", + "out": "dışında", + "There are no tickets available. ' .\n 'If you would like to assign a ticket to this attendee,' .\n ' please adjust the ticket's available quantity.": "Mevcut bilet yok. ' .\n 'Bu katılımcıya bir bilet atamak istiyorsanız,' .\n ' lütfen biletin mevcut miktarını ayarlayın.", + "The ticket price ID is invalid.": "Bilet fiyat kimliği geçersiz.", + "Ticket ID is not valid": "Bilet kimliği geçerli değil", + "There are no tickets available. ' .\n 'If you would like to assign this ticket to this attendee,' .\n ' please adjust the ticket's available quantity.": "Mevcut bilet yok. ' .\n 'Bu bileti bu katılımcıya atamak istiyorsanız,' .\n ' lütfen biletin mevcut miktarını ayarlayın.", + "Attendee ID is not valid": "Katılımcı kimliği geçerli değil", + "The invitation does not exist": "Davet mevcut değil", + "The invitation has already been accepted": "Davet zaten kabul edilmiş", + "Organizer :id not found": "Organizatör :id bulunamadı", + "Continue": "Devam Et", + "Event :id not found": "Etkinlik :id bulunamadı", + "You cannot change the currency of an event that has completed orders": "Tamamlanmış siparişleri olan bir etkinliğin para birimini değiştiremezsiniz", + "You must verify your account before you can update an event's status.\n You can resend the confirmation by visiting your profile page.": "Bir etkinliğin durumunu güncelleyebilmek için hesabınızı doğrulamalısınız.\n Profil sayfanızı ziyaret ederek onayı yeniden gönderebilirsiniz.", + "You cannot send messages until your account is verified.": "Hesabınız doğrulanana kadar mesaj gönderemezsiniz.", + "Order not found": "Sipariş bulunamadı", + "Order already cancelled": "Sipariş zaten iptal edildi", + "Failed to create attendee": "Katılımcı oluşturulamadı", + "This order is has already been processed": "Bu sipariş zaten işlendi", + "This order has expired": "Bu siparişin süresi doldu", + "This order has already been processed": "Bu sipariş zaten işlendi", + "There is an unexpected ticket price ID in the order": "Siparişte beklenmeyen bir bilet fiyat kimliği var", + "This event is not live.": "Bu etkinlik canlı değil.", + "Sorry, we could not verify your session. Please create a new order.": "Üzgünüz, oturumunuzu doğrulayamadık. Lütfen yeni bir sipariş oluşturun.", + "There is no Stripe data associated with this order.": "Bu siparişle ilişkili Stripe verisi yok.", + "There is already a refund pending for this order. '\n . 'Please wait for the refund to be processed before requesting another one.": "Bu sipariş için zaten bekleyen bir iade var. '\n . 'Başka bir tane istemeden önce iadenin işlenmesini bekleyin.", + "Promo code :code already exists": "Promosyon kodu :code zaten mevcut", + "The code :code is already in use for this event": ":code kodu bu etkinlik için zaten kullanımda", + "You cannot delete this question as there as answers associated with it. You can hide the question instead.": "Bu soruyu silemezsiniz çünkü onunla ilişkili cevaplar var. Bunun yerine soruyu gizleyebilirsiniz.", + "One or more of the ordered question IDs do not exist for the event.": "Sıralanan soru kimliklerinden bir veya daha fazlası etkinlik için mevcut değil.", + "You cannot delete this ticket because it has orders associated with it. You can hide it instead.": "Bu bileti silemezsiniz çünkü onunla ilişkili siparişler var. Bunun yerine gizleyebilirsiniz.", + "Ticket type cannot be changed as tickets have been registered for this type": "Bu tür için biletler kaydedildiği için bilet türü değiştirilemez", + "The ordered ticket IDs must exactly match all tickets for the event without missing or extra IDs.": "Sıralanan bilet kimlikleri, eksik veya fazla kimlik olmadan etkinlik için tüm biletlerle tam olarak eşleşmelidir.", + "No email change pending": "Bekleyen e-posta değişikliği yok", + "The email :email already exists on this account": ":email e-postası bu hesapta zaten mevcut", + "You are not authorized to perform this action.": "Bu eylemi gerçekleştirme yetkiniz yok.", + "Your account is not active.": "Hesabınız aktif değil.", + "Payload has expired or is invalid.": "Yük süresi doldu veya geçersiz.", + "Payload could not be decrypted.": "Yük şifresi çözülemedi.", + "Could not upload image to :disk. Check :disk is configured correctly": ":disk'e resim yüklenemedi. :disk'in doğru yapılandırıldığını kontrol edin", + "Could not upload image": "Resim yüklenemedi", + "Length must be a positive integer.": "Uzunluk pozitif bir tamsayı olmalıdır.", + "Prefix length exceeds the total desired token length.": "Önek uzunluğu, istenen toplam token uzunluğunu aşıyor.", + "A valid email is required": "Geçerli bir e-posta gerekli", + "The title field is required": "Başlık alanı gerekli", + "The attribute name is required": "Özellik adı gerekli", + "The attribute value is required": "Özellik değeri gerekli", + "The attribute is_public fields is required": "Özellik is_public alanı gerekli", + "Required questions have not been answered. You may need to reload the page.": "Gerekli sorular yanıtlanmamış. Sayfayı yeniden yüklemeniz gerekebilir.", + "This question is outdated. Please reload the page.": "Bu soru güncel değil. Lütfen sayfayı yeniden yükleyin.", + "Please select an option": "Lütfen bir seçenek seçin", + "This field is required.": "Bu alan gerekli.", + "This field must be less than 255 characters.": "Bu alan 255 karakterden az olmalıdır.", + "This field must be at least 2 characters.": "Bu alan en az 2 karakter olmalıdır.", + "Hello": "Merhaba", + "You have requested to reset your password for your account on :appName.": ":appName'deki hesabınız için şifre sıfırlama isteğinde bulundunuz.", + "Please click the link below to reset your password.": "Şifrenizi sıfırlamak için aşağıdaki bağlantıya tıklayın.", + "Reset Password": "Şifreyi Sıfırla", + "If you did not request a password reset, please ignore this email or reply to let us know.": "Şifre sıfırlama talebinde bulunmadıysanız, lütfen bu e-postayı görmezden gelin veya bize bildirmek için yanıtlayın.", + "Thank you": "Teşekkür ederiz", + "Your password has been reset for your account on :appName.": ":appName'deki hesabınızın şifresi sıfırlandı.", + "If you did not request a password reset, please immediately contact reset your password.": "Şifre sıfırlama talebinde bulunmadıysanız, lütfen derhal şifrenizi sıfırlayın.", + "You are receiving this communication because you are registered as an attendee for the following event:": "Bu iletişimi alıyorsunuz çünkü aşağıdaki etkinlik için katılımcı olarak kayıtlısınız:", + "If you believe you have received this email in error,": "Bu e-postayı hatalı aldığınızı düşünüyorsanız,", + "please contact the event organizer at": "lütfen etkinlik organizatörüyle iletişime geçin:", + "If you believe this is spam, please report it to": "Bunun spam olduğunu düşünüyorsanız, lütfen şuraya bildirin:", + "You're going to": "Gidiyorsunuz", + "Please find your ticket details below.": "Bilet detaylarınızı aşağıda bulabilirsiniz.", + "View Ticket": "Bileti Görüntüle", + "If you have any questions or need assistance, please reply to this email or contact the event organizer": "Herhangi bir sorunuz varsa veya yardıma ihtiyacınız varsa, lütfen bu e-postayı yanıtlayın veya etkinlik organizatörüyle iletişime geçin", + "at": "adresinde", + "Best regards,": "Saygılarımızla,", + "Your order for": "Siparişiniz", + "has been cancelled.": "iptal edildi.", + "Order #:": "Sipariş No.:", + "If you have any questions or need assistance, please respond to this email.": "Herhangi bir sorunuz varsa veya yardıma ihtiyacınız varsa, lütfen bu e-postayı yanıtlayın.", + "Your recent order for": "Son siparişiniz", + "was not successful.": "başarılı olmadı.", + "View Event Homepage": "Etkinlik Ana Sayfasını Görüntüle", + "If you have any questions or need assistance, feel free to reach out to our support team": "Herhangi bir sorunuz varsa veya yardıma ihtiyacınız varsa, destek ekibimizle iletişime geçmekten çekinmeyin", + "Best regards": "Saygılarımızla", + "You have received a refund of :refundAmount for the following event: :eventTitle.": "Aşağıdaki etkinlik için :refundAmount tutarında iade aldınız: :eventTitle.", + "You've received a new order!": "Yeni bir sipariş aldınız!", + "Congratulations! You've received a new order for ": "Tebrikler! Şunun için yeni bir sipariş aldınız: ", + "Please find the details below.": "Detayları aşağıda bulabilirsiniz.", + "Order Amount:": "Sipariş Tutarı:", + "Order ID:": "Sipariş Kimliği:", + "View Order": "Siparişi Görüntüle", + "Ticket": "Bilet", + "Price": "Fiyat", + "Total": "Toplam", + "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.": ":eventTitle için son siparişiniz başarılı olmadı. Ödemeyi tamamlarken siparişin süresi doldu. Sipariş için iade işlemi yaptık.", + "We apologize for the inconvenience. If you have any questions or need assistance, feel free to reach us at": "Rahatsızlık için özür dileriz. Herhangi bir sorunuz varsa veya yardıma ihtiyacınız varsa, bize şuradan ulaşmaktan çekinmeyin:", + "View Event Page": "Etkinlik Sayfasını Görüntüle", + "Hi.Events": "Hi.Events", + "Your Order is Confirmed! ": "Siparişiniz Onaylandı! ", + "Congratulations! Your order for :eventTitle on :eventDate at :eventTime was successful. Please find your order details below.": "Tebrikler! :eventDate tarihinde :eventTime saatinde :eventTitle için siparişiniz başarılı oldu. Sipariş detaylarınızı aşağıda bulabilirsiniz.", + "Event Details": "Etkinlik Detayları", + "Event Name:": "Etkinlik Adı:", + "Date & Time:": "Tarih ve Saat:", + "Order Summary": "Sipariş Özeti", + "Order Number:": "Sipariş Numarası:", + "Total Amount:": "Toplam Tutar:", + "View Order Summary & Tickets": "Sipariş Özeti ve Biletleri Görüntüle", + "If you have any questions or need assistance, feel free to reach out to our friendly support team at": "Herhangi bir sorunuz varsa veya yardıma ihtiyacınız varsa, dostane destek ekibimizle şuradan iletişime geçmekten çekinmeyin:", + "What's Next?": "Sırada Ne Var?", + "Download Tickets:": "Biletleri İndir:", + "Please download your tickets from the order summary page.": "Lütfen biletlerinizi sipariş özeti sayfasından indirin.", + "Prepare for the Event:": "Etkinliğe Hazırlanın:", + "Make sure to note the event date, time, and location.": "Etkinlik tarihini, saatini ve yerini not ettiğinizden emin olun.", + "Stay Updated:": "Güncel Kalın:", + "Keep an eye on your email for any updates from the event organizer.": "Etkinlik organizatöründen gelen güncellemeler için e-postanızı takip edin.", + "Hi :name": "Merhaba :name", + "Welcome to :appName! We're excited to have you aboard!": ":appName'e hoş geldiniz! Sizi aramızda görmekten heyecanlıyız!", + "To get started and activate your account, please click the link below to confirm your email address:": "Başlamak ve hesabınızı etkinleştirmek için, e-posta adresinizi onaylamak üzere aşağıdaki bağlantıya tıklayın:", + "Confirm Your Email": "E-postanızı Onaylayın", + "If you did not create an account with us, no further action is required. Your email address will not be used without confirmation.": "Bizimle bir hesap oluşturmadıysanız, başka bir işlem yapmanız gerekmez. E-posta adresiniz onay olmadan kullanılmayacaktır.", + "Best Regards,": "Saygılarımızla,", + "The :appName Team": ":appName Ekibi", + "You have requested to change your email address to :pendingEmail. Please click the link below to confirm this change.": "E-posta adresinizi :pendingEmail olarak değiştirme talebinde bulundunuz. Bu değişikliği onaylamak için aşağıdaki bağlantıya tıklayın.", + "Confirm email change": "E-posta değişikliğini onayla", + "If you did not request this change, please immediately change your password.": "Bu değişikliği talep etmediyseniz, lütfen derhal şifrenizi değiştirin.", + "Thanks,": "Teşekkürler,", + "You've been invited to join :appName.": ":appName'e katılmaya davet edildiniz.", + "To accept the invitation, please click the link below:": "Daveti kabul etmek için lütfen aşağıdaki bağlantıya tıklayın:", + "Accept Invitation": "Daveti Kabul Et", + "All rights reserved.": "Tüm hakları saklıdır.", + "Congratulations 🎉": "Tebrikler 🎉", + "Your order has been cancelled": "Siparişiniz iptal edildi", + "You've received a refund": "İade aldınız", + "Your Order is Confirmed!": "Siparişiniz Onaylandı!", + "Password reset": "Şifre sıfırlama", + "Your password has been reset": "Şifreniz sıfırlandı", + "You've been invited to join :appName": ":appName'e katılmaya davet edildiniz", + "There is already a refund pending for this order.\n Please wait for the refund to be processed before requesting another one.": "Bu sipariş için zaten bekleyen bir iade var.\n Başka bir tane istemeden önce iadenin işlenmesini bekleyin.", + "Your order wasn\\'t successful": "Siparişiniz başarılı olmadı", + "You\\'ve received a refund": "İade aldınız", + "You\\'ve been invited to join :appName": ":appName'e katılmaya davet edildiniz", + "You haven\\'t selected any tickets": "Herhangi bir bilet seçmediniz", + "There are no tickets available. ' .\n 'If you would like to assign a ticket to this attendee,' .\n ' please adjust the ticket\\'s available quantity.": "Mevcut bilet yok. ' .\n 'Bu katılımcıya bir bilet atamak istiyorsanız,' .\n ' lütfen biletin mevcut miktarını ayarlayın.", + "There are no tickets available. ' .\n 'If you would like to assign this ticket to this attendee,' .\n ' please adjust the ticket\\'s available quantity.": "Mevcut bilet yok. ' .\n 'Bu bileti bu katılımcıya atamak istiyorsanız,' .\n ' lütfen biletin mevcut miktarını ayarlayın.", + "You must verify your account before you can update an event\\'s status.\n You can resend the confirmation by visiting your profile page.": "Bir etkinliğin durumunu güncelleyebilmek için hesabınızı doğrulamalısınız.\n Profil sayfanızı ziyaret ederek onayı yeniden gönderebilirsiniz.", + "You\\'re going to": "Gidiyorsunuz", + "You\\'ve received a new order!": "Yeni bir sipariş aldınız!", + "Congratulations! You\\'ve received a new order for ": "Tebrikler! Şunun için yeni bir sipariş aldınız: ", + "What\\'s Next?": "Sırada Ne Var?", + "Welcome to :appName! We\\'re excited to have you aboard!": ":appName'e hoş geldiniz! Sizi aramızda görmekten heyecanlıyız!", + "You\\'ve been invited to join :appName.": ":appName'e katılmaya davet edildiniz.", + "Sent Date Oldest": "Gönderim Tarihi En Eski", + "Sent Date Newest": "Gönderim Tarihi En Yeni", + "Subject A-Z": "Konu A-Z", + "Subject Z-A": "Konu Z-A", + "There are no tickets available. If you would like to assign this ticket to this attendee, please adjust the ticket\\'s available quantity.": "Mevcut bilet yok. Bu bileti bu katılımcıya atamak istiyorsanız, lütfen biletin mevcut miktarını ayarlayın.", + "Name A-Z": "Ad A-Z", + "Name Z-A": "Ad Z-A", + "Updated oldest first": "Güncellenen en eski önce", + "Updated newest first": "Güncellenen en yeni önce", + "Most capacity used": "En çok kapasite kullanılan", + "Least capacity used": "En az kapasite kullanılan", + "Least capacity": "En az kapasite", + "Most capacity": "En çok kapasite", + "Sorry, these tickets are sold out": "Üzgünüz, bu biletler tükendi", + "The maximum number of tickets available is :max": "Mevcut maksimum bilet sayısı :max", + "Ticket is hidden without promo code": "Bilet promosyon kodu olmadan gizlenmiş", + "Ticket is sold out": "Bilet tükendi", + "Ticket is before sale start date": "Bilet satış başlangıç tarihinden önce", + "Ticket is after sale end date": "Bilet satış bitiş tarihinden sonra", + "Ticket is hidden": "Bilet gizlenmiş", + "Price is before sale start date": "Fiyat satış başlangıç tarihinden önce", + "Price is after sale end date": "Fiyat satış bitiş tarihinden sonra", + "Price is sold out": "Fiyat tükendi", + "Price is hidden": "Fiyat gizlenmiş", + "Expires soonest": "En yakın süre dolan", + "Expires latest": "En geç süre dolan", + "The expiration date must be after the activation date.": "Son kullanma tarihi, etkinleştirme tarihinden sonra olmalıdır.", + "The activation date must be before the expiration date.": "Etkinleştirme tarihi, son kullanma tarihinden önce olmalıdır.", + "Attendee :attendee_name is not allowed to check in using this check-in list": "Katılımcı :attendee_name bu check-in listesini kullanarak check in yapmasına izin verilmiyor", + "Invalid attendee code detected: :attendees ": "Geçersiz katılımcı kodu tespit edildi: :attendees ", + "Check-in list not found": "Check-in listesi bulunamadı", + "Attendee :attendee_name is already checked in": "Katılımcı :attendee_name zaten check in olmuş", + "Check-in list has expired": "Check-in listesinin süresi dolmuş", + "Check-in list is not active yes": "Check-in listesi henüz aktif değil", + "This attendee is not checked in": "Bu katılımcı check in olmamış", + "Attendee does not belong to this check-in list": "Katılımcı bu check-in listesine ait değil", + "Attendee :attendee_name\\'s ticket is cancelled": "Katılımcı :attendee_name'in bileti iptal edildi", + "Check-in list is not active yet": "Check-in listesi henüz aktif değil", + "The number of attendees does not match the number of tickets in the order": "Katılımcı sayısı siparişteki bilet sayısıyla eşleşmiyor", + "Product is required": "Ürün gerekli", + "Product price is required": "Ürün fiyatı gerekli", + "Please select at least one product.": "Lütfen en az bir ürün seçin.", + "The sale start date must be after the product sale start date.": "Satış başlangıç tarihi, ürün satış başlangıç tarihinden sonra olmalıdır.", + "You must select a product category.": "Bir ürün kategorisi seçmelisiniz.", + "Invalid direction. Must be either asc or desc": "Geçersiz yön. asc veya desc olmalıdır", + "DomainObject must be a valid :interface.": "DomainObject geçerli bir :interface olmalıdır.", + "Nested relationships must be an array of Relationship objects.": "İç içe ilişkiler, Relationship nesneleri dizisi olmalıdır.", + "OrderAndDirections must be an array of OrderAndDirection objects.": "OrderAndDirections, OrderAndDirection nesneleri dizisi olmalıdır.", + "Attendee :attendee_name\\'s product is cancelled": "Katılımcı :attendee_name'in ürünü iptal edildi", + "Tickets": "Biletler", + "There are no tickets available for this event.": "Bu etkinlik için mevcut bilet yok.", + "You haven\\'t selected any products": "Herhangi bir ürün seçmediniz", + "The maximum number of products available for :products is :max": ":products için mevcut maksimum ürün sayısı :max", + "You must order at least :min products for :product": ":product için en az :min ürün sipariş etmelisiniz", + "The product :product is sold out": ":product ürünü tükendi", + "The maximum number of products available for :product is :max": ":product için mevcut maksimum ürün sayısı :max", + "Sorry, these products are sold out": "Üzgünüz, bu ürünler tükendi", + "The maximum number of products available is :max": "Mevcut maksimum ürün sayısı :max", + "Product with id :id not found": ":id kimlikli ürün bulunamadı", + "You cannot delete this product because it has orders associated with it. You can hide it instead.": "Bu ürünü silemezsiniz çünkü onunla ilişkili siparişler var. Bunun yerine gizleyebilirsiniz.", + "Invalid product ids: :ids": "Geçersiz ürün kimlikleri: :ids", + "Product is hidden without promo code": "Ürün promosyon kodu olmadan gizlenmiş", + "Product is sold out": "Ürün tükendi", + "Product is before sale start date": "Ürün satış başlangıç tarihinden önce", + "Product is after sale end date": "Ürün satış bitiş tarihinden sonra", + "Product is hidden": "Ürün gizlenmiş", + "Cannot delete product price with id :id because it has sales": ":id kimlikli ürün fiyatı satışları olduğu için silinemez", + "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.": "Bu ürün kategorisini silemezsiniz çünkü şu ürünleri içeriyor: :products. Bu ürünler mevcut siparişlere bağlı. Bunu silmeye çalışmadan önce lütfen :product_name'i başka bir kategoriye taşıyın.", + "products": "ürünler", + "product": "ürün", + "Product category :productCategoryId has been deleted.": "Ürün kategorisi :productCategoryId silindi.", + "You cannot delete the last product category. Please create another category before deleting this one.": "Son ürün kategorisini silemezsiniz. Bunu silmeden önce lütfen başka bir kategori oluşturun.", + "The product category with ID :id was not found.": ":id kimlikli ürün kategorisi bulunamadı.", + "Expired": "Süresi Dolmuş", + "Limit Reached": "Sınıra Ulaşıldı", + "Deleted": "Silindi", + "Active": "Aktif", + "This ticket is invalid": "Bu bilet geçersiz", + "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.": "Mevcut bilet yok. ' .\n 'Bu katılımcıya bir ürün atamak istiyorsanız,' .\n ' lütfen ürünün mevcut miktarını ayarlayın.", + "The product price ID is invalid.": "Ürün fiyat kimliği geçersiz.", + "Product ID is not valid": "Ürün kimliği geçerli değil", + "There are no products available. If you would like to assign this product to this attendee, please adjust the product\\'s available quantity.": "Mevcut ürün yok. Bu ürünü bu katılımcıya atamak istiyorsanız, lütfen ürünün mevcut miktarını ayarlayın.", + "There is an unexpected product price ID in the order": "Siparişte beklenmeyen bir ürün fiyat kimliği var", + "Product type cannot be changed as products have been registered for this type": "Bu tür için ürünler kaydedildiği için ürün türü değiştirilemez", + "The ordered category IDs must exactly match all categories for the event without missing or extra IDs.": "Sıralanan kategori kimlikleri, eksik veya fazla kimlik olmadan etkinlik için tüm kategorilerle tam olarak eşleşmelidir.", + "The ordered product IDs must exactly match all products for the event without missing or extra IDs.": "Sıralanan ürün kimlikleri, eksik veya fazla kimlik olmadan etkinlik için tüm ürünlerle tam olarak eşleşmelidir.", + "This product is outdated. Please reload the page.": "Bu ürün güncel değil. Lütfen sayfayı yeniden yükleyin.", + "Reserved": "Rezerve Edildi", + "Cancelled": "İptal Edildi", + "Completed": "Tamamlandı", + "Awaiting offline payment": "Çevrimdışı ödeme bekleniyor", + "ID": "Kimlik", + "First Name": "Ad", + "Last Name": "Soyad", + "Email": "E-posta", + "Status": "Durum", + "Is Checked In": "Check In Yapıldı mı", + "Checked In At": "Check In Zamanı", + "Product ID": "Ürün Kimliği", + "Product Name": "Ürün Adı", + "Event ID": "Etkinlik Kimliği", + "Public ID": "Genel Kimlik", + "Short ID": "Kısa Kimlik", + "Created Date": "Oluşturulma Tarihi", + "Last Updated Date": "Son Güncelleme Tarihi", + "Notes": "Notlar", + "Total Before Additions": "Eklemeler Öncesi Toplam", + "Total Gross": "Brüt Toplam", + "Total Tax": "Toplam Vergi", + "Total Fee": "Toplam Ücret", + "Total Refunded": "Toplam İade Edilen", + "Payment Status": "Ödeme Durumu", + "Refund Status": "İade Durumu", + "Currency": "Para Birimi", + "Created At": "Oluşturulma Zamanı", + "Payment Gateway": "Ödeme Ağ Geçidi", + "Is Partially Refunded": "Kısmen İade Edildi mi", + "Is Fully Refunded": "Tamamen İade Edildi mi", + "Is Free Order": "Ücretsiz Sipariş mi", + "Is Manually Created": "Manuel Olarak Oluşturuldu mu", + "Billing Address": "Fatura Adresi", + "Promo Code": "Promosyon Kodu", + "Failed to handle incoming Stripe webhook": "Gelen Stripe webhook'u işlenemedi", + "Notes must be less than 2000 characters": "Notlar 2000 karakterden az olmalıdır", + "Invalid payment provider selected.": "Geçersiz ödeme sağlayıcısı seçildi.", + "Payment instructions are required when offline payments are enabled.": "Çevrimdışı ödemeler etkinleştirildiğinde ödeme talimatları gereklidir.", + "The invoice prefix may only contain letters, numbers, and hyphens.": "Fatura öneki sadece harf, rakam ve tire içerebilir.", + "The organization name is required when invoicing is enabled.": "Faturalandırma etkinleştirildiğinde organizasyon adı gereklidir.", + "The organization address is required when invoicing is enabled.": "Faturalandırma etkinleştirildiğinde organizasyon adresi gereklidir.", + "The invoice start number must be at least 1.": "Fatura başlangıç numarası en az 1 olmalıdır.", + "There is no default account configuration available": "Mevcut varsayılan hesap yapılandırması yok", + "Product price ID is not valid": "Ürün fiyat kimliği geçerli değil", + "Invoice": "Fatura", + "Editing order with ID: :id": ":id kimlikli sipariş düzenleniyor", + "Marking order as paid": "Sipariş ödenmiş olarak işaretleniyor", + "Received a :event Stripe event, which has no handler": "İşleyicisi olmayan :event Stripe etkinliği alındı", + "Order is not in the correct status to transition to offline payment": "Sipariş çevrimdışı ödemeye geçiş için doğru durumda değil", + "Order reservation has expired": "Sipariş rezervasyonunun süresi doldu", + "Offline payments are not enabled for this event": "Bu etkinlik için çevrimdışı ödemeler etkinleştirilmemiş", + "There are no products available in this category": "Bu kategoride mevcut ürün yok", + "Webhook not found": "Webhook bulunamadı", + "Unable to check in as attendee :attendee_name\\'s order is awaiting payment": "Katılımcı :attendee_name'in siparişi ödeme beklediği için check in yapılamıyor", + "Attendee :attendee_name\\'s order cannot be marked as paid. Please check your event settings": "Katılımcı :attendee_name'in siparişi ödenmiş olarak işaretlenemiyor. Lütfen etkinlik ayarlarınızı kontrol edin", + "Invoice already exists": "Fatura zaten mevcut", + "Invoice not found": "Fatura bulunamadı", + "Order is not awaiting offline payment": "Sipariş çevrimdışı ödeme beklemiyor", + "Refund already processed": "İade zaten işlendi", + "Stripe refund successful": "Stripe iade başarılı", + "There are no tickets available for this event": "Bu etkinlik için mevcut bilet yok", + "Address line 1 is required": "Adres satırı 1 gerekli", + "City is required": "Şehir gerekli", + "Zip or postal code is required": "Posta kodu gerekli", + "Country is required": "Ülke gerekli", + "If you did not request a password reset, please immediately reset your password.": "Şifre sıfırlama talebinde bulunmadıysanız, lütfen derhal şifrenizi sıfırlayın.", + "ℹ️ Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "ℹ️ Siparişiniz ödeme bekliyor. Biletler verildi ancak ödeme alınana kadar geçerli olmayacak.", + "ℹ️ This order is pending payment. Please mark the payment as received on the order management page once payment is received.": "ℹ️ Bu sipariş ödeme bekliyor. Ödeme alındığında lütfen sipariş yönetimi sayfasında ödemeyi alındı olarak işaretleyin.", + "Order Status:": "Sipariş Durumu:", + "Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "Siparişiniz ödeme bekliyor. Biletler verildi ancak ödeme alınana kadar geçerli olmayacak.", + "Payment Instructions": "Ödeme Talimatları", + "Please follow the instructions below to complete your payment.": "Ödemenizi tamamlamak için aşağıdaki talimatları izleyin.", + "Invoice Number": "Fatura Numarası", + "Date Issued": "Düzenlenme Tarihi", + "Due Date": "Vade Tarihi", + "Amount Due": "Vadesi Gelen Tutar", + "Billed To": "Fatura Edildi", + "DESCRIPTION": "AÇIKLAMA", + "RATE": "ORAN", + "QTY": "MİKTAR", + "AMOUNT": "TUTAR", + "Subtotal": "Ara Toplam", + "Total Discount": "Toplam İndirim", + "Total Service Fee": "Toplam Hizmet Ücreti", + "Total Amount": "Toplam Tutar", + "For any queries, please contact us at": "Herhangi bir soru için lütfen bizimle iletişime geçin:", + "Tax Information": "Vergi Bilgileri", + "Sales Ascending": "Satışlar Artan", + "Sales Descending": "Satışlar Azalan", + "Revenue Ascending": "Gelir Artan", + "Revenue Descending": "Gelir Azalan", + "Social": "Sosyal", + "Food & Drink": "Yemek ve İçecek", + "Charity": "Hayır", + "Music": "Müzik", + "Art": "Sanat", + "Comedy": "Komedi", + "Theater": "Tiyatro", + "Business": "İş", + "Tech": "Teknoloji", + "Education": "Eğitim", + "Workshop": "Atölye", + "Sports": "Spor", + "Festival": "Festival", + "Nightlife": "Gece Hayatı", + "Other": "Diğer", + "Name": "Ad", + "Code": "Kod", + "Total Sales": "Toplam Satış", + "Total Sales Gross": "Toplam Brüt Satış", + "Updated At": "Güncellenme Zamanı", + "Question": "Soru", + "Answer": "Cevap", + "Order ID": "Sipariş Kimliği", + "Order Email": "Sipariş E-postası", + "Attendee Name": "Katılımcı Adı", + "Attendee Email": "Katılımcı E-postası", + "Product": "Ürün", + "Order URL": "Sipariş URL'si", + "Attendee Answers": "Katılımcı Cevapları", + "Order Name": "Sipariş Adı", + "Order Answers": "Sipariş Cevapları", + "Product Answers": "Ürün Cevapları", + "Payment Provider": "Ödeme Sağlayıcısı", + "Invalid invite token": "Geçersiz davet token'ı", + "Affiliate not found": "Satış ortağı bulunamadı", + "Organizer with ID :organizerId is not live and user is not authenticated": ":organizerId kimlikli organizatör canlı değil ve kullanıcı kimlik doğrulaması yapılmamış", + "Message sent successfully": "Mesaj başarıyla gönderildi", + "Your email has been successfully verified!": "E-postanız başarıyla doğrulandı!", + "The image must be at least 600 pixels wide and 50 pixels tall, and no more than 4000 pixels wide and 4000 pixels tall.": "Resim en az 600 piksel genişliğinde ve 50 piksel yüksekliğinde olmalı, ayrıca 4000 piksel genişliğinden ve 4000 piksel yüksekliğinden fazla olmamalıdır.", + "The image must be at least :minWidth x :minHeight pixels.": "Resim en az :minWidth x :minHeight piksel olmalıdır.", + "The entity ID is required when type is provided.": "Tür sağlandığında varlık kimliği gereklidir.", + "The type is required when entity ID is provided.": "Varlık kimliği sağlandığında tür gereklidir.", + "Your confirmation code for :app_name is :code": ":app_name için onay kodunuz :code", + "New message from :name": ":name'den yeni mesaj", + "An affiliate with this code already exists for this event": "Bu etkinlik için bu koda sahip bir satış ortağı zaten mevcut", + "New password must be different from the old password.": "Yeni şifre eski şifreden farklı olmalıdır.", + "Due to issues with spam, you must contact us to enable your account for sending messages. ' .\n 'Please contact us at :email": "Spam sorunları nedeniyle, mesaj gönderme için hesabınızı etkinleştirmek üzere bizimle iletişime geçmelisiniz. ' .\n 'Lütfen şu adresten bizimle iletişime geçin: :email", + "Sorry, is expired or not in a valid state.": "Üzgünüz, süres dolmuş veya geçerli durumda değil.", + "Organizer not found": "Organizatör bulunamadı", + "You must verify your account before you can update an organizer\\'s status.\n You can resend the confirmation by visiting your profile page.": "Bir organizatörün durumunu güncelleyebilmek için hesabınızı doğrulamalısınız.\n Profil sayfanızı ziyaret ederek onayı yeniden gönderebilirsiniz.", + "Your email address has already been verified.": "E-posta adresiniz zaten doğrulanmış.", + "The verification code is invalid or has expired.": "Doğrulama kodu geçersiz veya süresi dolmuş.", + "Job not found": "İş bulunamadı", + "Export file not found": "Dışa aktarma dosyası bulunamadı", + "Job completed successfully": "İş başarıyla tamamlandı", + "Job is still in progress": "İş hala devam ediyor", + "Email Subject": "E-posta Konusu", + "© :year :app_name. All rights reserved.": "© :year :app_name. Tüm hakları saklıdır.", + "Need help?": "Yardıma mı ihtiyacınız var?", + "Contact Support": "Destek ile İletişime Geçin", + "Additional Information": "Ek Bilgiler", + "If you have any questions or need assistance, please contact": "Herhangi bir sorunuz varsa veya yardıma ihtiyacınız varsa, lütfen iletişime geçin", + "Hello :name": "Merhaba :name", + "You have received a new message from": "Şu kişiden yeni bir mesaj aldınız:", + "Reply to :name": ":name'e Yanıtla", + "This message was sent via your organizer contact form.": "Bu mesaj organizatör iletişim formunuz aracılığıyla gönderildi.", + "Your email confirmation code is:": "E-posta onay kodunuz:" +} \ No newline at end of file diff --git a/backend/lang/tr/auth.php b/backend/lang/tr/auth.php new file mode 100644 index 0000000000..c86d84af71 --- /dev/null +++ b/backend/lang/tr/auth.php @@ -0,0 +1,20 @@ + 'Bu kimlik bilgileri kayıtlarımızla eşleşmiyor.', + 'password' => 'Sağlanan şifre yanlış.', + 'throttle' => 'Çok fazla giriş denemesi. Lütfen :seconds saniye sonra tekrar deneyin.', + +]; diff --git a/backend/lang/tr/pagination.php b/backend/lang/tr/pagination.php new file mode 100644 index 0000000000..8c760579ea --- /dev/null +++ b/backend/lang/tr/pagination.php @@ -0,0 +1,19 @@ + '« Önceki', + 'next' => 'Sonraki »', + +]; diff --git a/backend/lang/tr/passwords.php b/backend/lang/tr/passwords.php new file mode 100644 index 0000000000..c9db36d47b --- /dev/null +++ b/backend/lang/tr/passwords.php @@ -0,0 +1,22 @@ + 'Şifreniz sıfırlandı.', + 'sent' => 'Şifre sıfırlama bağlantınızı e-posta olarak gönderdik.', + 'throttled' => 'Lütfen tekrar denemeden önce bekleyin.', + 'token' => 'Bu şifre sıfırlama token\'ı geçersiz.', + 'user' => "Bu e-posta adresine sahip bir kullanıcı bulamıyoruz.", + +]; diff --git a/backend/lang/tr/validation.php b/backend/lang/tr/validation.php new file mode 100644 index 0000000000..3d32b9491d --- /dev/null +++ b/backend/lang/tr/validation.php @@ -0,0 +1,184 @@ + ':attribute alanı kabul edilmelidir.', + 'accepted_if' => ':other :value olduğunda :attribute alanı kabul edilmelidir.', + 'active_url' => ':attribute alanı geçerli bir URL olmalıdır.', + 'after' => ':attribute alanı :date tarihinden sonra bir tarih olmalıdır.', + 'after_or_equal' => ':attribute alanı :date tarihinden sonra veya eşit bir tarih olmalıdır.', + 'alpha' => ':attribute alanı sadece harfler içermelidir.', + 'alpha_dash' => ':attribute alanı sadece harfler, rakamlar, tireler ve alt çizgiler içermelidir.', + 'alpha_num' => ':attribute alanı sadece harfler ve rakamlar içermelidir.', + 'array' => ':attribute alanı bir dizi olmalıdır.', + 'ascii' => ':attribute alanı sadece tek baytlık alfanümerik karakterler ve semboller içermelidir.', + 'before' => ':attribute alanı :date tarihinden önce bir tarih olmalıdır.', + 'before_or_equal' => ':attribute alanı :date tarihinden önce veya eşit bir tarih olmalıdır.', + 'between' => [ + 'array' => ':attribute alanı :min ile :max arasında öğe içermelidir.', + 'file' => ':attribute alanı :min ile :max kilobayt arasında olmalıdır.', + 'numeric' => ':attribute alanı :min ile :max arasında olmalıdır.', + 'string' => ':attribute alanı :min ile :max karakter arasında olmalıdır.', + ], + 'boolean' => ':attribute alanı doğru veya yanlış olmalıdır.', + 'confirmed' => ':attribute alanı onayı eşleşmiyor.', + 'current_password' => 'Şifre yanlış.', + 'date' => ':attribute alanı geçerli bir tarih olmalıdır.', + 'date_equals' => ':attribute alanı :date tarihine eşit bir tarih olmalıdır.', + 'date_format' => ':attribute alanı :format formatına uygun olmalıdır.', + 'decimal' => ':attribute alanı :decimal ondalık basamağa sahip olmalıdır.', + 'declined' => ':attribute alanı reddedilmelidir.', + 'declined_if' => ':other :value olduğunda :attribute alanı reddedilmelidir.', + 'different' => ':attribute alanı ile :other farklı olmalıdır.', + 'digits' => ':attribute alanı :digits basamak olmalıdır.', + 'digits_between' => ':attribute alanı :min ile :max basamak arasında olmalıdır.', + 'dimensions' => ':attribute alanı geçersiz resim boyutlarına sahip.', + 'distinct' => ':attribute alanında yinelenen değer var.', + 'doesnt_end_with' => ':attribute alanı şunlardan biriyle bitmemelidir: :values.', + 'doesnt_start_with' => ':attribute alanı şunlardan biriyle başlamamalıdır: :values.', + 'email' => ':attribute alanı geçerli bir e-posta adresi olmalıdır.', + 'ends_with' => ':attribute alanı şunlardan biriyle bitmelidir: :values.', + 'enum' => 'Seçilen :attribute geçersiz.', + 'exists' => 'Seçilen :attribute geçersiz.', + 'file' => ':attribute alanı bir dosya olmalıdır.', + 'filled' => ':attribute alanı bir değere sahip olmalıdır.', + 'gt' => [ + 'array' => ':attribute alanı :value öğeden fazla içermelidir.', + 'file' => ':attribute alanı :value kilobayttan büyük olmalıdır.', + 'numeric' => ':attribute alanı :value değerinden büyük olmalıdır.', + 'string' => ':attribute alanı :value karakterden fazla olmalıdır.', + ], + 'gte' => [ + 'array' => ':attribute alanı :value öğe veya daha fazla içermelidir.', + 'file' => ':attribute alanı :value kilobaytta veya daha büyük olmalıdır.', + 'numeric' => ':attribute alanı :value değerinde veya daha büyük olmalıdır.', + 'string' => ':attribute alanı :value karakter veya daha fazla olmalıdır.', + ], + 'image' => ':attribute alanı bir resim olmalıdır.', + 'in' => 'Seçilen :attribute geçersiz.', + 'in_array' => ':attribute alanı :other içinde bulunmalıdır.', + 'integer' => ':attribute alanı bir tam sayı olmalıdır.', + 'ip' => ':attribute alanı geçerli bir IP adresi olmalıdır.', + 'ipv4' => ':attribute alanı geçerli bir IPv4 adresi olmalıdır.', + 'ipv6' => ':attribute alanı geçerli bir IPv6 adresi olmalıdır.', + 'json' => ':attribute alanı geçerli bir JSON dizesi olmalıdır.', + 'lowercase' => ':attribute alanı küçük harf olmalıdır.', + 'lt' => [ + 'array' => ':attribute alanı :value öğeden az içermelidir.', + 'file' => ':attribute alanı :value kilobayttan küçük olmalıdır.', + 'numeric' => ':attribute alanı :value değerinden küçük olmalıdır.', + 'string' => ':attribute alanı :value karakterden az olmalıdır.', + ], + 'lte' => [ + 'array' => ':attribute alanı :value öğeden fazla içermemelidir.', + 'file' => ':attribute alanı :value kilobaytta veya daha küçük olmalıdır.', + 'numeric' => ':attribute alanı :value değerinde veya daha küçük olmalıdır.', + 'string' => ':attribute alanı :value karakter veya daha az olmalıdır.', + ], + 'mac_address' => ':attribute alanı geçerli bir MAC adresi olmalıdır.', + 'max' => [ + 'array' => ':attribute alanı :max öğeden fazla içermemelidir.', + 'file' => ':attribute alanı :max kilobayttan büyük olmamalıdır.', + 'numeric' => ':attribute alanı :max değerinden büyük olmamalıdır.', + 'string' => ':attribute alanı :max karakterden fazla olmamalıdır.', + ], + 'max_digits' => ':attribute alanı :max basamaktan fazla içermemelidir.', + 'mimes' => ':attribute alanı şu türde bir dosya olmalıdır: :values.', + 'mimetypes' => ':attribute alanı şu türde bir dosya olmalıdır: :values.', + 'min' => [ + 'array' => ':attribute alanı en az :min öğe içermelidir.', + 'file' => ':attribute alanı en az :min kilobayt olmalıdır.', + 'numeric' => ':attribute alanı en az :min olmalıdır.', + 'string' => ':attribute alanı en az :min karakter olmalıdır.', + ], + 'min_digits' => ':attribute alanı en az :min basamak içermelidir.', + 'missing' => ':attribute alanı eksik olmalıdır.', + 'missing_if' => ':other :value olduğunda :attribute alanı eksik olmalıdır.', + 'missing_unless' => ':other :value olmadığı sürece :attribute alanı eksik olmalıdır.', + 'missing_with' => ':values mevcut olduğunda :attribute alanı eksik olmalıdır.', + 'missing_with_all' => ':values mevcut olduğunda :attribute alanı eksik olmalıdır.', + 'multiple_of' => ':attribute alanı :value değerinin katı olmalıdır.', + 'not_in' => 'Seçilen :attribute geçersiz.', + 'not_regex' => ':attribute alanı formatı geçersiz.', + 'numeric' => ':attribute alanı bir sayı olmalıdır.', + 'password' => [ + 'letters' => ':attribute alanı en az bir harf içermelidir.', + 'mixed' => ':attribute alanı en az bir büyük ve bir küçük harf içermelidir.', + 'numbers' => ':attribute alanı en az bir rakam içermelidir.', + 'symbols' => ':attribute alanı en az bir sembol içermelidir.', + 'uncompromised' => 'Verilen :attribute bir veri sızıntısında görünmüş. Lütfen farklı bir :attribute seçin.', + ], + 'present' => ':attribute alanı mevcut olmalıdır.', + 'prohibited' => ':attribute alanı yasaktır.', + 'prohibited_if' => ':other :value olduğunda :attribute alanı yasaktır.', + 'prohibited_unless' => ':other :values içinde olmadığı sürece :attribute alanı yasaktır.', + 'prohibits' => ':attribute alanı :other\'ın mevcut olmasını yasaklar.', + 'regex' => ':attribute alanı formatı geçersiz.', + 'required' => ':attribute alanı gereklidir.', + 'required_array_keys' => ':attribute alanı şunlar için girişler içermelidir: :values.', + 'required_if' => ':other :value olduğunda :attribute alanı gereklidir.', + 'required_if_accepted' => ':other kabul edildiğinde :attribute alanı gereklidir.', + 'required_unless' => ':other :values içinde olmadığı sürece :attribute alanı gereklidir.', + 'required_with' => ':values mevcut olduğunda :attribute alanı gereklidir.', + 'required_with_all' => ':values mevcut olduğunda :attribute alanı gereklidir.', + 'required_without' => ':values mevcut olmadığında :attribute alanı gereklidir.', + 'required_without_all' => ':values hiçbiri mevcut olmadığında :attribute alanı gereklidir.', + 'same' => ':attribute alanı :other ile eşleşmelidir.', + 'size' => [ + 'array' => ':attribute alanı :size öğe içermelidir.', + 'file' => ':attribute alanı :size kilobayt olmalıdır.', + 'numeric' => ':attribute alanı :size olmalıdır.', + 'string' => ':attribute alanı :size karakter olmalıdır.', + ], + 'starts_with' => ':attribute alanı şunlardan biriyle başlamalıdır: :values.', + 'string' => ':attribute alanı bir metin olmalıdır.', + 'timezone' => ':attribute alanı geçerli bir saat dilimi olmalıdır.', + 'unique' => ':attribute zaten alınmış.', + 'uploaded' => ':attribute yüklenemedi.', + 'uppercase' => ':attribute alanı büyük harf olmalıdır.', + 'url' => ':attribute alanı geçerli bir URL olmalıdır.', + 'ulid' => ':attribute alanı geçerli bir ULID olmalıdır.', + 'uuid' => ':attribute alanı geçerli bir UUID olmalıdır.', + + /* + |-------------------------------------------------------------------------- + | Custom Validation Language Lines + |-------------------------------------------------------------------------- + | + | Here you may specify custom validation messages for attributes using the + | convention "attribute.rule" to name the lines. This makes it quick to + | specify a specific custom language line for a given attribute rule. + | + */ + + 'custom' => [ + 'attribute-name' => [ + 'rule-name' => 'özel-mesaj', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Custom Validation Attributes + |-------------------------------------------------------------------------- + | + | The following language lines are used to swap our attribute placeholder + | with something more reader friendly such as "E-Mail Address" instead + | of "email". This simply helps us make our message more expressive. + | + */ + + 'attributes' => [], + +]; diff --git a/backend/resources/views/emails/custom-template.blade.php b/backend/resources/views/emails/custom-template.blade.php new file mode 100644 index 0000000000..8499f9cf7f --- /dev/null +++ b/backend/resources/views/emails/custom-template.blade.php @@ -0,0 +1,13 @@ +{{-- Custom Liquid Template Wrapper --}} + +{!! $renderedBody !!} + +@if(isset($renderedCta)) + + {{ $renderedCta['label'] }} + +@endif + +{!! $eventSettings->getGetEmailFooterHtml() !!} + + diff --git a/backend/resources/views/emails/organizer/contact-message.blade.php b/backend/resources/views/emails/organizer/contact-message.blade.php index 80d45e11ac..85bc00ca1e 100644 --- a/backend/resources/views/emails/organizer/contact-message.blade.php +++ b/backend/resources/views/emails/organizer/contact-message.blade.php @@ -2,6 +2,7 @@ @php /** @var string $senderName */ @endphp @php /** @var string $senderEmail */ @endphp @php /** @var string $messageContent */ @endphp +@php /** @var string $replySubject */ @endphp @php /** @see \HiEvents\Mail\Organizer\OrganizerContactEmail */ @endphp @@ -10,16 +11,16 @@ {{ __('You have received a new message from') }} **{{ $senderName }}** ({{ $senderEmail }}). ---- +
{!! nl2br(e($messageContent)) !!} ---- +
- + {{ __('Reply to :name', ['name' => $senderName]) }} {{ __('This message was sent via your organizer contact form.') }} - \ No newline at end of file + diff --git a/backend/routes/api.php b/backend/routes/api.php index 82a07f9022..3551b29609 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -3,6 +3,7 @@ use HiEvents\Http\Actions\Accounts\CreateAccountAction; use HiEvents\Http\Actions\Accounts\GetAccountAction; use HiEvents\Http\Actions\Accounts\Stripe\CreateStripeConnectAccountAction; +use HiEvents\Http\Actions\Accounts\Stripe\GetStripeConnectAccountsAction; use HiEvents\Http\Actions\Accounts\UpdateAccountAction; use HiEvents\Http\Actions\Affiliates\CreateAffiliateAction; use HiEvents\Http\Actions\Affiliates\DeleteAffiliateAction; @@ -58,6 +59,18 @@ use HiEvents\Http\Actions\Events\UpdateEventStatusAction; use HiEvents\Http\Actions\EventSettings\EditEventSettingsAction; use HiEvents\Http\Actions\EventSettings\GetEventSettingsAction; +use HiEvents\Http\Actions\EmailTemplates\CreateOrganizerEmailTemplateAction; +use HiEvents\Http\Actions\EmailTemplates\CreateEventEmailTemplateAction; +use HiEvents\Http\Actions\EmailTemplates\UpdateOrganizerEmailTemplateAction; +use HiEvents\Http\Actions\EmailTemplates\UpdateEventEmailTemplateAction; +use HiEvents\Http\Actions\EmailTemplates\GetOrganizerEmailTemplatesAction; +use HiEvents\Http\Actions\EmailTemplates\GetEventEmailTemplatesAction; +use HiEvents\Http\Actions\EmailTemplates\DeleteOrganizerEmailTemplateAction; +use HiEvents\Http\Actions\EmailTemplates\DeleteEventEmailTemplateAction; +use HiEvents\Http\Actions\EmailTemplates\PreviewOrganizerEmailTemplateAction; +use HiEvents\Http\Actions\EmailTemplates\PreviewEventEmailTemplateAction; +use HiEvents\Http\Actions\EmailTemplates\GetAvailableTokensAction; +use HiEvents\Http\Actions\EmailTemplates\GetDefaultEmailTemplateAction; use HiEvents\Http\Actions\EventSettings\PartialEditEventSettingsAction; use HiEvents\Http\Actions\Images\CreateImageAction; use HiEvents\Http\Actions\Images\DeleteImageAction; @@ -192,6 +205,7 @@ function (Router $router): void { // Accounts $router->get('/accounts/{account_id?}', GetAccountAction::class); $router->put('/accounts/{account_id?}', UpdateAccountAction::class); + $router->get('/accounts/{account_id}/stripe/connect_accounts', GetStripeConnectAccountsAction::class); $router->post('/accounts/{account_id}/stripe/connect', CreateStripeConnectAccountAction::class); // Organizers @@ -207,6 +221,15 @@ function (Router $router): void { $router->get('/organizers/{organizer_id}/settings', GetOrganizerSettingsAction::class); $router->patch('/organizers/{organizer_id}/settings', PartialUpdateOrganizerSettingsAction::class); + // Email Templates - Organizer level + $router->get('/organizers/{organizerId}/email-templates', GetOrganizerEmailTemplatesAction::class); + $router->get('/email-templates/defaults', GetDefaultEmailTemplateAction::class); + $router->post('/organizers/{organizerId}/email-templates', CreateOrganizerEmailTemplateAction::class); + $router->put('/organizers/{organizerId}/email-templates/{templateId}', UpdateOrganizerEmailTemplateAction::class); + $router->delete('/organizers/{organizerId}/email-templates/{templateId}', DeleteOrganizerEmailTemplateAction::class); + $router->post('/organizers/{organizerId}/email-templates/preview', PreviewOrganizerEmailTemplateAction::class); + $router->get('/email-templates/tokens/{templateType}', GetAvailableTokensAction::class); + // Taxes and Fees $router->post('/accounts/{account_id}/taxes-and-fees', CreateTaxOrFeeAction::class); $router->get('/accounts/{account_id}/taxes-and-fees', GetTaxOrFeeAction::class); @@ -239,6 +262,13 @@ function (Router $router): void { // Stats $router->get('/events/{event_id}/stats', GetEventStatsAction::class); + // Email Templates - Event level + $router->get('/events/{eventId}/email-templates', GetEventEmailTemplatesAction::class); + $router->post('/events/{eventId}/email-templates', CreateEventEmailTemplateAction::class); + $router->put('/events/{eventId}/email-templates/{templateId}', UpdateEventEmailTemplateAction::class); + $router->delete('/events/{eventId}/email-templates/{templateId}', DeleteEventEmailTemplateAction::class); + $router->post('/events/{eventId}/email-templates/preview', PreviewEventEmailTemplateAction::class); + // Attendees $router->post('/events/{event_id}/attendees', CreateAttendeeAction::class); $router->get('/events/{event_id}/attendees', GetAttendeesAction::class); diff --git a/backend/tests/Feature/Http/Actions/EmailTemplates/BasicEmailTemplateTest.php b/backend/tests/Feature/Http/Actions/EmailTemplates/BasicEmailTemplateTest.php new file mode 100644 index 0000000000..07345956af --- /dev/null +++ b/backend/tests/Feature/Http/Actions/EmailTemplates/BasicEmailTemplateTest.php @@ -0,0 +1,87 @@ + 1], [ + 'id' => 1, + 'name' => 'Default', + 'is_system_default' => true, + 'application_fees' => [ + 'percentage' => 1.5, + 'fixed' => 0, + ] + ]); + + // Create user with account + $password = 'password123'; + $this->user = User::factory()->password($password)->withAccount()->create(); + + // Login to get JWT token + $loginResponse = $this->postJson('/auth/login', [ + 'email' => $this->user->email, + 'password' => $password, + ]); + + $this->authToken = $loginResponse->headers->get('X-Auth-Token'); + } + + public function test_token_endpoint_works(): void + { + $response = $this->getJson('/email-templates/tokens/order_confirmation', [ + 'Authorization' => 'Bearer ' . $this->authToken, + ]); + + $response->assertStatus(ResponseCodes::HTTP_OK) + ->assertJsonStructure([ + 'tokens' => [ + '*' => [ + 'token', + 'description', + 'example', + ], + ], + ]); + + $tokens = $response->json('tokens'); + $this->assertNotEmpty($tokens); + $this->assertGreaterThan(5, count($tokens)); // Should have multiple tokens + } + + public function test_token_endpoint_validates_template_type(): void + { + $response = $this->getJson('/email-templates/tokens/invalid_type', [ + 'Authorization' => 'Bearer ' . $this->authToken, + ]); + + $response->assertStatus(ResponseCodes::HTTP_BAD_REQUEST); + } + + public function test_endpoints_require_authentication(): void + { + // Test token endpoint without auth + $response = $this->getJson('/email-templates/tokens/order_confirmation'); + $response->assertStatus(ResponseCodes::HTTP_INTERNAL_SERVER_ERROR); + + // Test organizer endpoint without auth (using dummy ID) + $response = $this->getJson("/organizers/999/email-templates"); + $response->assertStatus(ResponseCodes::HTTP_INTERNAL_SERVER_ERROR); + } +} diff --git a/backend/tests/Feature/Http/Actions/EmailTemplates/EmailTemplateTokenTest.php b/backend/tests/Feature/Http/Actions/EmailTemplates/EmailTemplateTokenTest.php new file mode 100644 index 0000000000..030a2f06f5 --- /dev/null +++ b/backend/tests/Feature/Http/Actions/EmailTemplates/EmailTemplateTokenTest.php @@ -0,0 +1,159 @@ + 1], [ + 'id' => 1, + 'name' => 'Default', + 'is_system_default' => true, + 'application_fees' => [ + 'percentage' => 1.5, + 'fixed' => 0, + ] + ]); + + // Create user with account + $password = 'password123'; + $this->user = User::factory()->password($password)->withAccount()->create(); + + // Get the account created by withAccount() + $this->account = $this->user->accounts()->first(); + + // Login to get JWT token + $loginResponse = $this->postJson('/auth/login', [ + 'email' => $this->user->email, + 'password' => $password, + ]); + + $this->authToken = $loginResponse->headers->get('X-Auth-Token'); + } + + public function test_can_get_order_confirmation_tokens(): void + { + $response = $this->getJson('/email-templates/tokens/order_confirmation', [ + 'Authorization' => 'Bearer ' . $this->authToken, + ]); + + $response->assertStatus(ResponseCodes::HTTP_OK) + ->assertJsonStructure([ + 'tokens' => [ + '*' => [ + 'token', + 'description', + 'example', + ], + ], + ]); + + $tokens = $response->json('tokens'); + $this->assertNotEmpty($tokens); + + $firstNameToken = collect($tokens)->firstWhere('token', '{{ order_first_name }}'); + $this->assertNotNull($firstNameToken); + $this->assertEquals('The first name of the person who placed the order', $firstNameToken['description']); + + $lastNameToken = collect($tokens)->firstWhere('token', '{{ order_last_name }}'); + $this->assertNotNull($lastNameToken); + $this->assertEquals('The last name of the person who placed the order', $lastNameToken['description']); + } + + public function test_can_get_attendee_ticket_tokens(): void + { + $response = $this->getJson('/email-templates/tokens/attendee_ticket', [ + 'Authorization' => 'Bearer ' . $this->authToken, + ]); + + $response->assertStatus(ResponseCodes::HTTP_OK) + ->assertJsonStructure([ + 'tokens' => [ + '*' => [ + 'token', + 'description', + 'example', + ], + ], + ]); + + $tokens = $response->json('tokens'); + $this->assertNotEmpty($tokens); + + $attendeeNameToken = collect($tokens)->firstWhere('token', '{{ attendee_name }}'); + $this->assertNotNull($attendeeNameToken); + } + + public function test_invalid_template_type_returns_validation_error(): void + { + $response = $this->getJson('/email-templates/tokens/invalid_type', [ + 'Authorization' => 'Bearer ' . $this->authToken, + ]); + + $response->assertStatus(ResponseCodes::HTTP_BAD_REQUEST); + } + + public function test_unauthenticated_user_cannot_access_tokens(): void + { + $response = $this->getJson('/email-templates/tokens/order_confirmation'); + + $response->assertStatus(ResponseCodes::HTTP_INTERNAL_SERVER_ERROR); + } + + public function test_tokens_include_order_specific_tokens(): void + { + $response = $this->getJson('/email-templates/tokens/order_confirmation', [ + 'Authorization' => 'Bearer ' . $this->authToken, + ]); + + $response->assertStatus(ResponseCodes::HTTP_OK); + + $tokens = $response->json('tokens'); + $tokenNames = collect($tokens)->pluck('token')->toArray(); + + $this->assertContains('{{ event_title }}', $tokenNames); + $this->assertContains('{{ order_number }}', $tokenNames); + $this->assertContains('{{ order_total }}', $tokenNames); + $this->assertContains('{{ organizer_name }}', $tokenNames); + } + + public function test_tokens_have_proper_structure(): void + { + $response = $this->getJson('/email-templates/tokens/order_confirmation', [ + 'Authorization' => 'Bearer ' . $this->authToken, + ]); + + $response->assertStatus(ResponseCodes::HTTP_OK); + + $tokens = $response->json('tokens'); + + foreach ($tokens as $token) { + $this->assertArrayHasKey('token', $token); + $this->assertArrayHasKey('description', $token); + $this->assertArrayHasKey('example', $token); + + $this->assertNotEmpty($token['description']); + // Most tokens should start with {{ and end with }} + if (str_starts_with($token['token'], '{{')) { + $this->assertStringEndsWith('}}', $token['token']); + } + } + } +} \ No newline at end of file diff --git a/backend/tests/Unit/DomainObjects/Enums/StripePlatformTest.php b/backend/tests/Unit/DomainObjects/Enums/StripePlatformTest.php new file mode 100644 index 0000000000..acbacfb41b --- /dev/null +++ b/backend/tests/Unit/DomainObjects/Enums/StripePlatformTest.php @@ -0,0 +1,29 @@ +assertEquals(StripePlatform::CANADA, StripePlatform::fromString('ca')); + $this->assertEquals(StripePlatform::IRELAND, StripePlatform::fromString('ie')); + $this->assertNull(StripePlatform::fromString(null)); + $this->assertNull(StripePlatform::fromString('invalid')); + } + + public function test_to_string_returns_value(): void + { + $this->assertEquals('ca', StripePlatform::CANADA->toString()); + $this->assertEquals('ie', StripePlatform::IRELAND->toString()); + } + + public function test_get_all_values(): void + { + $expected = ['ca', 'ie']; + $this->assertEquals($expected, StripePlatform::getAllValues()); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandlerTest.php new file mode 100644 index 0000000000..1ac7ca3c33 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandlerTest.php @@ -0,0 +1,90 @@ +config = m::mock(Repository::class); + $stripeClientFactory = m::mock(StripeClientFactory::class); + $stripeConfigurationService = m::mock(StripeConfigurationService::class); + $stripeAccountSyncService = m::mock(StripeAccountSyncService::class); + + $this->handler = new CreateStripeConnectAccountHandler( + $accountRepository, + $accountStripePlatformRepository, + $databaseManager, + $logger, + $this->config, + $stripeClientFactory, + $stripeConfigurationService, + $stripeAccountSyncService, + ); + } + + public function testHandleThrowsExceptionWhenSaasModeDisabled(): void + { + $dto = new CreateStripeConnectAccountDTO(accountId: 1); + + $this->config + ->shouldReceive('get') + ->with('app.saas_mode_enabled') + ->andReturn(false); + + $this->expectException(SaasModeEnabledException::class); + $this->expectExceptionMessage('Stripe Connect Account creation is only available in Saas Mode.'); + + $this->handler->handle($dto); + } + + public function testHandleAllowsExecutionWhenSaasModeEnabled(): void + { + $dto = new CreateStripeConnectAccountDTO(accountId: 1); + + $this->config + ->shouldReceive('get') + ->with('app.saas_mode_enabled') + ->andReturn(true); + + // We expect this to NOT throw the SaasModeEnabledException + // It will fail later due to missing mocks, but that proves SaaS mode check passed + try { + $this->handler->handle($dto); + } catch (SaasModeEnabledException $e) { + $this->fail('Should not throw SaasModeEnabledException when saas mode is enabled'); + } catch (\Exception $e) { + // Expected - will fail on missing mocks, but SaaS check passed + $this->assertTrue(true); + } + } + + protected function tearDown(): void + { + m::close(); + parent::tearDown(); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/Application/Handlers/Account/Payment/Stripe/GetStripeConnectAccountsHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Account/Payment/Stripe/GetStripeConnectAccountsHandlerTest.php new file mode 100644 index 0000000000..aa2f48f223 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Account/Payment/Stripe/GetStripeConnectAccountsHandlerTest.php @@ -0,0 +1,148 @@ +accountRepository = m::mock(AccountRepositoryInterface::class); + $stripeClientFactory = m::mock(StripeClientFactory::class); + $stripeAccountSyncService = m::mock(StripeAccountSyncService::class); + $logger = m::mock(LoggerInterface::class); + + $this->handler = new GetStripeConnectAccountsHandler( + $this->accountRepository, + $stripeClientFactory, + $stripeAccountSyncService, + $logger, + ); + } + + public function testHandleReturnsEmptyCollectionWhenNoStripePlatforms(): void + { + $accountId = 1; + $account = m::mock(AccountDomainObject::class); + + $this->accountRepository + ->shouldReceive('loadRelation') + ->with(AccountStripePlatformDomainObject::class) + ->andReturnSelf(); + + $this->accountRepository + ->shouldReceive('findById') + ->with($accountId) + ->andReturn($account); + + $account + ->shouldReceive('getAccountStripePlatforms') + ->andReturn(null); + + $account + ->shouldReceive('getActiveStripeAccountId') + ->andReturn(null); + + $account + ->shouldReceive('isStripeSetupComplete') + ->andReturn(false); + + $result = $this->handler->handle($accountId); + + $this->assertSame($account, $result->account); + $this->assertTrue($result->stripeConnectAccounts->isEmpty()); + $this->assertNull($result->primaryStripeAccountId); + $this->assertFalse($result->hasCompletedSetup); + } + + public function testHandleReturnsEmptyCollectionWhenStripePlatformsEmpty(): void + { + $accountId = 1; + $account = m::mock(AccountDomainObject::class); + $emptyCollection = collect([]); + + $this->accountRepository + ->shouldReceive('loadRelation') + ->with(AccountStripePlatformDomainObject::class) + ->andReturnSelf(); + + $this->accountRepository + ->shouldReceive('findById') + ->with($accountId) + ->andReturn($account); + + $account + ->shouldReceive('getAccountStripePlatforms') + ->andReturn($emptyCollection); + + $account + ->shouldReceive('getActiveStripeAccountId') + ->andReturn(null); + + $account + ->shouldReceive('isStripeSetupComplete') + ->andReturn(false); + + $result = $this->handler->handle($accountId); + + $this->assertTrue($result->stripeConnectAccounts->isEmpty()); + } + + public function testHandleSkipsAccountWithoutStripeAccountId(): void + { + $accountId = 1; + $account = m::mock(AccountDomainObject::class); + $stripePlatform = m::mock(AccountStripePlatformDomainObject::class); + $stripePlatforms = collect([$stripePlatform]); + + $this->accountRepository + ->shouldReceive('loadRelation') + ->with(AccountStripePlatformDomainObject::class) + ->andReturnSelf(); + + $this->accountRepository + ->shouldReceive('findById') + ->with($accountId) + ->andReturn($account); + + $account + ->shouldReceive('getAccountStripePlatforms') + ->andReturn($stripePlatforms); + + $stripePlatform + ->shouldReceive('getStripeAccountId') + ->andReturn(null); + + $account + ->shouldReceive('getActiveStripeAccountId') + ->andReturn(null); + + $account + ->shouldReceive('isStripeSetupComplete') + ->andReturn(false); + + $result = $this->handler->handle($accountId); + + $this->assertTrue($result->stripeConnectAccounts->isEmpty()); + } + + protected function tearDown(): void + { + m::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Domain/Email/EmailTemplateServiceTest.php b/backend/tests/Unit/Services/Domain/Email/EmailTemplateServiceTest.php new file mode 100644 index 0000000000..821e17574c --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Email/EmailTemplateServiceTest.php @@ -0,0 +1,241 @@ +mockRepository = Mockery::mock(EmailTemplateRepositoryInterface::class); + $this->mockLiquidRenderer = Mockery::mock(LiquidTemplateRenderer::class); + $this->mockTokenBuilder = Mockery::mock(EmailTokenContextBuilder::class); + + $this->emailTemplateService = new EmailTemplateService( + $this->mockRepository, + $this->mockLiquidRenderer, + $this->mockTokenBuilder + ); + } + + public function test_gets_event_level_template_when_exists(): void + { + $eventTemplate = $this->createMockTemplate( + 'event-template-id', + EmailTemplateType::ORDER_CONFIRMATION, + 1, + 1, + 1, + true + ); + + $this->mockRepository + ->shouldReceive('findByTypeWithFallback') + ->with( + EmailTemplateType::ORDER_CONFIRMATION, + 1, // accountId + 1, // eventId + 1 // organizerId + ) + ->once() + ->andReturn($eventTemplate); + + $result = $this->emailTemplateService->getTemplateByType( + EmailTemplateType::ORDER_CONFIRMATION, + 1, // accountId + 1, // eventId + 1 // organizerId + ); + + $this->assertSame($eventTemplate, $result); + } + + public function test_falls_back_to_organizer_template_when_no_event_template(): void + { + $organizerTemplate = $this->createMockTemplate( + 'organizer-template-id', + EmailTemplateType::ORDER_CONFIRMATION, + 1, + 1, + null, + true + ); + + $this->mockRepository + ->shouldReceive('findByTypeWithFallback') + ->with( + EmailTemplateType::ORDER_CONFIRMATION, + 1, // accountId + 1, // eventId + 1 // organizerId + ) + ->once() + ->andReturn($organizerTemplate); + + $result = $this->emailTemplateService->getTemplateByType( + EmailTemplateType::ORDER_CONFIRMATION, + 1, // accountId + 1, // eventId + 1 // organizerId + ); + + $this->assertSame($organizerTemplate, $result); + } + + public function test_returns_null_when_no_templates_exist(): void + { + $this->mockRepository + ->shouldReceive('findByTypeWithFallback') + ->with( + EmailTemplateType::ORDER_CONFIRMATION, + 1, // accountId + 1, // eventId + 1 // organizerId + ) + ->once() + ->andReturn(null); + + $result = $this->emailTemplateService->getTemplateByType( + EmailTemplateType::ORDER_CONFIRMATION, + 1, // accountId + 1, // eventId + 1 // organizerId + ); + + $this->assertNull($result); + } + + public function test_gets_organizer_level_template_when_no_event_id(): void + { + $organizerTemplate = $this->createMockTemplate( + 'organizer-template-id', + EmailTemplateType::ATTENDEE_TICKET, + 1, + 1, + null, + true + ); + + $this->mockRepository + ->shouldReceive('findByTypeWithFallback') + ->with( + EmailTemplateType::ATTENDEE_TICKET, + 1, // accountId + null, // eventId + 1 // organizerId + ) + ->once() + ->andReturn($organizerTemplate); + + $result = $this->emailTemplateService->getTemplateByType( + EmailTemplateType::ATTENDEE_TICKET, + 1, // accountId + null, // eventId + 1 // organizerId + ); + + $this->assertSame($organizerTemplate, $result); + } + + public function test_prefers_active_templates_over_inactive(): void + { + $activeTemplate = $this->createMockTemplate( + 'active-template-id', + EmailTemplateType::ORDER_CONFIRMATION, + 1, + 1, + 1, + true + ); + + $this->mockRepository + ->shouldReceive('findByTypeWithFallback') + ->with( + EmailTemplateType::ORDER_CONFIRMATION, + 1, // accountId + 1, // eventId + 1 // organizerId + ) + ->once() + ->andReturn($activeTemplate); + + $result = $this->emailTemplateService->getTemplateByType( + EmailTemplateType::ORDER_CONFIRMATION, + 1, // accountId + 1, // eventId + 1 // organizerId + ); + + $this->assertSame($activeTemplate, $result); + $this->assertTrue($result->getIsActive()); + } + + public function test_handles_different_template_types(): void + { + $attendeeTicketTemplate = $this->createMockTemplate( + 'ticket-template-id', + EmailTemplateType::ATTENDEE_TICKET, + 1, + 1, + null, + true + ); + + $this->mockRepository + ->shouldReceive('findByTypeWithFallback') + ->with( + EmailTemplateType::ATTENDEE_TICKET, + 1, // accountId + null, // eventId + 1 // organizerId + ) + ->once() + ->andReturn($attendeeTicketTemplate); + + $result = $this->emailTemplateService->getTemplateByType( + EmailTemplateType::ATTENDEE_TICKET, + 1, // accountId + null, // eventId + 1 // organizerId + ); + + $this->assertSame($attendeeTicketTemplate, $result); + $this->assertEquals(EmailTemplateType::ATTENDEE_TICKET->value, $result->getTemplateType()); + } + + private function createMockTemplate( + string $id, + EmailTemplateType $type, + int $accountId, + int $organizerId, + ?int $eventId, + bool $isActive + ): EmailTemplateDomainObject { + return Mockery::mock(EmailTemplateDomainObject::class, [ + 'getId' => $id, + 'getTemplateType' => $type->value, + 'getAccountId' => $accountId, + 'getOrganizerId' => $organizerId, + 'getEventId' => $eventId, + 'getSubject' => 'Test Subject', + 'getBody' => 'Test Body {{ customer.name }}', + 'getIsActive' => $isActive, + 'getEngine' => 'liquid', + ]); + } +} diff --git a/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php b/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php new file mode 100644 index 0000000000..37ff777ca2 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php @@ -0,0 +1,225 @@ +contextBuilder = new EmailTokenContextBuilder(); + } + + public function test_builds_order_confirmation_context(): void + { + $order = $this->createMockOrder(); + $event = $this->createMockEvent(); + $organizer = $this->createMockOrganizer(); + $eventSettings = $this->createMockEventSettings(); + + $context = $this->contextBuilder->buildOrderConfirmationContext( + $order, + $event, + $organizer, + $eventSettings + ); + + $this->assertIsArray($context); + $this->assertArrayHasKey('order', $context); + $this->assertArrayHasKey('event', $context); + $this->assertArrayHasKey('organizer', $context); + $this->assertArrayHasKey('settings', $context); + + // Test order context + $this->assertEquals('ORD-123456', $context['order']['number']); + $this->assertEquals('$9,999.00', $context['order']['total']); // Updated expected format + $this->assertEquals('John', $context['order']['first_name']); + $this->assertEquals('Doe', $context['order']['last_name']); + $this->assertEquals('john@example.com', $context['order']['email']); + + // Test event context + $this->assertEquals('Amazing Event', $context['event']['title']); + $this->assertEquals('This is an amazing event', $context['event']['description']); + + // Test organizer context + $this->assertEquals('Great Organizer', $context['organizer']['name']); + $this->assertEquals('contact@organizer.com', $context['organizer']['email']); + + // Test settings context + $this->assertEquals('support@event.com', $context['settings']['support_email']); + } + + public function test_builds_attendee_ticket_context(): void + { + $attendee = $this->createMockAttendee(); + $order = $this->createMockOrder(); + $event = $this->createMockEvent(); + $organizer = $this->createMockOrganizer(); + $eventSettings = $this->createMockEventSettings(); + + $context = $this->contextBuilder->buildAttendeeTicketContext( + $attendee, + $order, + $event, + $organizer, + $eventSettings + ); + + $this->assertIsArray($context); + $this->assertArrayHasKey('attendee', $context); + $this->assertArrayHasKey('ticket', $context); + $this->assertArrayHasKey('order', $context); + $this->assertArrayHasKey('event', $context); + $this->assertArrayHasKey('organizer', $context); + + // Test attendee context + $this->assertEquals('Jane Smith', $context['attendee']['name']); + $this->assertEquals('jane@example.com', $context['attendee']['email']); + + // Test ticket context + $this->assertEquals('General Admission', $context['ticket']['name']); + $this->assertEquals('$4,999.00', $context['ticket']['price']); // Updated expected format + + // Test event context + $this->assertEquals('Amazing Event', $context['event']['title']); + + // Test organizer context + $this->assertEquals('Great Organizer', $context['organizer']['name']); + } + + public function test_whitelists_only_allowed_tokens_for_order_confirmation(): void + { + $order = $this->createMockOrder(); + $event = $this->createMockEvent(); + $organizer = $this->createMockOrganizer(); + $eventSettings = $this->createMockEventSettings(); + + $context = $this->contextBuilder->buildOrderConfirmationContext( + $order, + $event, + $organizer, + $eventSettings + ); + + // Test that expected nested structure exists + $this->assertArrayHasKey('order', $context); + $this->assertArrayHasKey('event', $context); + $this->assertArrayHasKey('organizer', $context); + $this->assertArrayHasKey('settings', $context); + + // Test that expected properties are included + $this->assertArrayHasKey('number', $context['order']); + $this->assertArrayHasKey('first_name', $context['order']); + $this->assertArrayHasKey('title', $context['event']); + } + + public function test_whitelists_only_allowed_tokens_for_attendee_ticket(): void + { + $attendee = $this->createMockAttendee(); + $order = $this->createMockOrder(); + $event = $this->createMockEvent(); + $organizer = $this->createMockOrganizer(); + $eventSettings = $this->createMockEventSettings(); + + $context = $this->contextBuilder->buildAttendeeTicketContext( + $attendee, + $order, + $event, + $organizer, + $eventSettings + ); + + // Test that expected nested structure exists + $this->assertArrayHasKey('attendee', $context); + $this->assertArrayHasKey('ticket', $context); + $this->assertArrayHasKey('event', $context); + $this->assertArrayHasKey('organizer', $context); + + // Test that expected properties are included + $this->assertArrayHasKey('name', $context['attendee']); + $this->assertArrayHasKey('name', $context['ticket']); + $this->assertArrayHasKey('title', $context['event']); + } + + private function createMockOrder(): OrderDomainObject + { + $orderItem = Mockery::mock(OrderItemDomainObject::class, [ + 'getProductPriceId' => 123, + 'getPrice' => 4999, + 'getItemName' => 'General Admission', + ]); + + $orderItems = new Collection([$orderItem]); + + return Mockery::mock(OrderDomainObject::class, [ + 'getPublicId' => 'ORD-123456', + 'getTotalGross' => 9999, + 'getFirstName' => 'John', + 'getLastName' => 'Doe', + 'getEmail' => 'john@example.com', + 'getCreatedAt' => '2024-01-15 10:30:00', + 'getShortId' => 'ABC123', + 'isOrderAwaitingOfflinePayment' => false, + 'getPaymentProvider' => PaymentProviders::STRIPE->value, + 'getOrderItems' => $orderItems, + 'getCurrency' => 'USD', + 'getLocale' => 'en', + ]); + } + + private function createMockEvent(): EventDomainObject + { + return Mockery::mock(EventDomainObject::class, [ + 'getTitle' => 'Amazing Event', + 'getDescription' => 'This is an amazing event', + 'getStartDate' => '2024-02-15 19:00:00', + 'getEndDate' => '2024-02-15 22:00:00', + 'getTimezone' => 'America/New_York', + 'getCurrency' => 'USD', + 'getId' => 1, + ]); + } + + private function createMockOrganizer(): OrganizerDomainObject + { + return Mockery::mock(OrganizerDomainObject::class, [ + 'getName' => 'Great Organizer', + 'getEmail' => 'contact@organizer.com', + ]); + } + + private function createMockEventSettings(): EventSettingDomainObject + { + return Mockery::mock(EventSettingDomainObject::class, [ + 'getSupportEmail' => 'support@event.com', + 'getOfflinePaymentInstructions' => 'Pay by bank transfer', + 'getPostCheckoutMessage' => 'Thank you for your purchase!', + 'getLocationDetails' => null, + ]); + } + + private function createMockAttendee(): AttendeeDomainObject + { + return Mockery::mock(AttendeeDomainObject::class, [ + 'getFirstName' => 'Jane', + 'getLastName' => 'Smith', + 'getEmail' => 'jane@example.com', + 'getProductPriceId' => 123, + 'getShortId' => 'ATT123', + ]); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsCancellationServiceTest.php b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsCancellationServiceTest.php new file mode 100644 index 0000000000..042b44af75 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsCancellationServiceTest.php @@ -0,0 +1,350 @@ +eventStatisticsRepository = Mockery::mock(EventStatisticRepositoryInterface::class); + $this->eventDailyStatisticRepository = Mockery::mock(EventDailyStatisticRepositoryInterface::class); + $this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class); + $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class); + $this->databaseManager = Mockery::mock(DatabaseManager::class); + $this->logger = Mockery::mock(LoggerInterface::class); + $this->retrier = Mockery::mock(Retrier::class); + + $this->service = new EventStatisticsCancellationService( + $this->eventStatisticsRepository, + $this->eventDailyStatisticRepository, + $this->attendeeRepository, + $this->orderRepository, + $this->logger, + $this->databaseManager, + $this->retrier + ); + } + + public function testDecrementForCancelledOrderSuccess(): void + { + $eventId = 1; + $orderId = 123; + $orderDate = '2024-01-15 10:30:00'; + + // Create mock order items + $ticketOrderItem1 = Mockery::mock(OrderItemDomainObject::class); + $ticketOrderItem1->shouldReceive('getQuantity')->andReturn(2); + + $ticketOrderItem2 = Mockery::mock(OrderItemDomainObject::class); + $ticketOrderItem2->shouldReceive('getQuantity')->andReturn(1); + + $orderItems = new Collection([$ticketOrderItem1, $ticketOrderItem2]); + $ticketOrderItems = new Collection([$ticketOrderItem1, $ticketOrderItem2]); + + // Create mock order + $order = Mockery::mock(OrderDomainObject::class); + $order->shouldReceive('getEventId')->andReturn($eventId); + $order->shouldReceive('getId')->andReturn($orderId); + $order->shouldReceive('getCreatedAt')->andReturn($orderDate); + $order->shouldReceive('getOrderItems')->andReturn($orderItems); + $order->shouldReceive('getTicketOrderItems')->andReturn($ticketOrderItems); + $order->shouldReceive('getStatisticsDecrementedAt')->andReturnNull(); + + // Mock order repository to return order with relations + $this->orderRepository + ->shouldReceive('loadRelation') + ->with(OrderItemDomainObject::class) + ->andReturnSelf(); + + $this->orderRepository + ->shouldReceive('findById') + ->with($orderId) + ->andReturn($order); + + // Mock aggregate event statistics + $eventStatistics = Mockery::mock(EventStatisticDomainObject::class); + $eventStatistics->shouldReceive('getId')->andReturn(1); + $eventStatistics->shouldReceive('getAttendeesRegistered')->andReturn(10); + $eventStatistics->shouldReceive('getProductsSold')->andReturn(15); + $eventStatistics->shouldReceive('getOrdersCreated')->andReturn(5); + $eventStatistics->shouldReceive('getOrdersCancelled')->andReturn(2); + $eventStatistics->shouldReceive('getVersion')->andReturn(5); + + // Mock daily event statistics + $eventDailyStatistic = Mockery::mock(EventDailyStatisticDomainObject::class); + $eventDailyStatistic->shouldReceive('getAttendeesRegistered')->andReturn(8); + $eventDailyStatistic->shouldReceive('getProductsSold')->andReturn(12); + $eventDailyStatistic->shouldReceive('getOrdersCreated')->andReturn(4); + $eventDailyStatistic->shouldReceive('getOrdersCancelled')->andReturn(1); + $eventDailyStatistic->shouldReceive('getVersion')->andReturn(3); + + // Mock attendee repository to return 2 active attendees (1 was already cancelled) + $activeAttendee1 = Mockery::mock(AttendeeDomainObject::class); + $activeAttendee2 = Mockery::mock(AttendeeDomainObject::class); + $this->attendeeRepository + ->shouldReceive('findWhereIn') + ->with( + 'status', + [AttendeeStatus::ACTIVE->name, AttendeeStatus::AWAITING_PAYMENT->name], + ['order_id' => $orderId] + ) + ->andReturn(new Collection([$activeAttendee1, $activeAttendee2])); + + // Set up retrier to execute the action immediately + $this->retrier + ->shouldReceive('retry') + ->andReturnUsing(function ($callableAction) { + return $callableAction(1); + }); + + // Set up database transaction + $this->databaseManager + ->shouldReceive('transaction') + ->andReturnUsing(function ($callback) { + return $callback(); + }); + + // Expect finding aggregate statistics + $this->eventStatisticsRepository + ->shouldReceive('findFirstWhere') + ->with(['event_id' => $eventId]) + ->andReturn($eventStatistics); + + // Expect updating aggregate statistics with decremented values + // Note: We use full order quantities for products_sold since products don't get "uncancelled" + $this->eventStatisticsRepository + ->shouldReceive('updateWhere') + ->with( + [ + 'attendees_registered' => 8, // 10 - 2 (2 active attendees) + 'products_sold' => 12, // 15 - 3 (full order quantities) + 'orders_created' => 4, // 5 - 1 + 'orders_cancelled' => 3, // 2 + 1 + 'version' => 6, // 5 + 1 + ], + [ + 'id' => 1, + 'version' => 5, + ] + ) + ->andReturn(1); + + // Expect finding daily statistics + $this->eventDailyStatisticRepository + ->shouldReceive('findFirstWhere') + ->with([ + 'event_id' => $eventId, + 'date' => '2024-01-15', + ]) + ->andReturn($eventDailyStatistic); + + // Expect updating daily statistics with decremented values + // Note: We use full order quantities for products_sold since products don't get "uncancelled" + $this->eventDailyStatisticRepository + ->shouldReceive('updateWhere') + ->with( + [ + 'attendees_registered' => 6, // 8 - 2 (2 active attendees) + 'products_sold' => 9, // 12 - 3 (full order quantities) + 'orders_created' => 3, // 4 - 1 + 'orders_cancelled' => 2, // 1 + 1 + 'version' => 4, // 3 + 1 + ], + [ + 'event_id' => $eventId, + 'date' => '2024-01-15', + 'version' => 3, + ] + ) + ->andReturn(1); + + // Expect marking statistics as decremented + $this->orderRepository + ->shouldReceive('updateFromArray') + ->with($orderId, Mockery::on(function ($data) { + return array_key_exists('statistics_decremented_at', $data) && $data['statistics_decremented_at'] !== null; + })) + ->once(); + + // Expect logging + $this->logger->shouldReceive('info')->atLeast()->once(); + + // Execute + $this->service->decrementForCancelledOrder($order); + + $this->assertTrue(true); + } + + public function testSkipsDecrementWhenAlreadyDecremented(): void + { + $orderId = 123; + $eventId = 1; + $decrementedAt = '2024-01-15 09:00:00'; + + // Create mock order with statistics already decremented + $order = Mockery::mock(OrderDomainObject::class); + $order->shouldReceive('getId')->andReturn($orderId); + $order->shouldReceive('getEventId')->andReturn($eventId); + $order->shouldReceive('getStatisticsDecrementedAt')->andReturn($decrementedAt); + + // Mock order repository + $this->orderRepository + ->shouldReceive('loadRelation') + ->with(OrderItemDomainObject::class) + ->andReturnSelf(); + + $this->orderRepository + ->shouldReceive('findById') + ->with($orderId) + ->andReturn($order); + + // Expect logging that statistics were already decremented + $this->logger + ->shouldReceive('info') + ->with( + 'Statistics already decremented for cancelled order', + [ + 'order_id' => $orderId, + 'event_id' => $eventId, + 'decremented_at' => $decrementedAt, + ] + ) + ->once(); + + // Should not call any update methods + $this->eventStatisticsRepository->shouldNotReceive('updateWhere'); + $this->eventDailyStatisticRepository->shouldNotReceive('updateWhere'); + $this->orderRepository->shouldNotReceive('updateFromArray'); + + // Execute + $this->service->decrementForCancelledOrder($order); + + $this->assertTrue(true); + } + + public function testDecrementForCancelledAttendee(): void + { + $eventId = 1; + $orderDate = '2024-01-15 10:30:00'; + $attendeeCount = 2; + + // Mock aggregate event statistics + $eventStatistics = Mockery::mock(EventStatisticDomainObject::class); + $eventStatistics->shouldReceive('getId')->andReturn(1); + $eventStatistics->shouldReceive('getAttendeesRegistered')->andReturn(10); + $eventStatistics->shouldReceive('getProductsSold')->andReturn(15); + $eventStatistics->shouldReceive('getVersion')->andReturn(5); + + // Mock daily event statistics + $eventDailyStatistic = Mockery::mock(EventDailyStatisticDomainObject::class); + $eventDailyStatistic->shouldReceive('getAttendeesRegistered')->andReturn(8); + $eventDailyStatistic->shouldReceive('getProductsSold')->andReturn(12); + $eventDailyStatistic->shouldReceive('getVersion')->andReturn(3); + + // Set up retrier to execute the action immediately + $this->retrier + ->shouldReceive('retry') + ->andReturnUsing(function ($callableAction) { + return $callableAction(1); + }); + + // Set up database transaction + $this->databaseManager + ->shouldReceive('transaction') + ->andReturnUsing(function ($callback) { + return $callback(); + }); + + // Expect finding aggregate statistics + $this->eventStatisticsRepository + ->shouldReceive('findFirstWhere') + ->with(['event_id' => $eventId]) + ->andReturn($eventStatistics); + + // Expect updating aggregate statistics with decremented values + // Note: products_sold should NOT be affected by individual attendee cancellations + $this->eventStatisticsRepository + ->shouldReceive('updateWhere') + ->with( + [ + 'attendees_registered' => 8, // 10 - 2 + 'version' => 6, // 5 + 1 + ], + [ + 'id' => 1, + 'version' => 5, + ] + ) + ->andReturn(1); + + // Expect finding daily statistics + $this->eventDailyStatisticRepository + ->shouldReceive('findFirstWhere') + ->with([ + 'event_id' => $eventId, + 'date' => '2024-01-15', + ]) + ->andReturn($eventDailyStatistic); + + // Expect updating daily statistics with decremented values + // Note: products_sold should NOT be affected by individual attendee cancellations + $this->eventDailyStatisticRepository + ->shouldReceive('updateWhere') + ->with( + [ + 'attendees_registered' => 6, // 8 - 2 + 'version' => 4, // 3 + 1 + ], + [ + 'event_id' => $eventId, + 'date' => '2024-01-15', + 'version' => 3, + ] + ) + ->andReturn(1); + + // Expect logging + $this->logger->shouldReceive('info')->twice(); // One for aggregate, one for daily + + // Execute + $this->service->decrementForCancelledAttendee($eventId, $orderDate, $attendeeCount); + + $this->assertTrue(true); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsIncrementServiceTest.php b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsIncrementServiceTest.php new file mode 100644 index 0000000000..107a1257e9 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsIncrementServiceTest.php @@ -0,0 +1,353 @@ +promoCodeRepository = Mockery::mock(PromoCodeRepositoryInterface::class); + $this->productRepository = Mockery::mock(ProductRepositoryInterface::class); + $this->eventStatisticsRepository = Mockery::mock(EventStatisticRepositoryInterface::class); + $this->eventDailyStatisticRepository = Mockery::mock(EventDailyStatisticRepositoryInterface::class); + $this->databaseManager = Mockery::mock(DatabaseManager::class); + $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class); + $this->logger = Mockery::mock(LoggerInterface::class); + $this->retrier = Mockery::mock(Retrier::class); + + $this->service = new EventStatisticsIncrementService( + $this->promoCodeRepository, + $this->productRepository, + $this->eventStatisticsRepository, + $this->eventDailyStatisticRepository, + $this->databaseManager, + $this->orderRepository, + $this->logger, + $this->retrier + ); + } + + public function testIncrementForOrderWithExistingStatistics(): void + { + $eventId = 1; + $orderId = 123; + $promoCodeId = 456; + $orderDate = '2024-01-15 10:30:00'; + + // Create mock order items + $ticketOrderItem1 = Mockery::mock(OrderItemDomainObject::class); + $ticketOrderItem1->shouldReceive('getQuantity')->andReturn(2); + $ticketOrderItem1->shouldReceive('getProductId')->andReturn(1); + $ticketOrderItem1->shouldReceive('getTotalBeforeAdditions')->andReturn(100.00); + + $ticketOrderItem2 = Mockery::mock(OrderItemDomainObject::class); + $ticketOrderItem2->shouldReceive('getQuantity')->andReturn(1); + $ticketOrderItem2->shouldReceive('getProductId')->andReturn(2); + $ticketOrderItem2->shouldReceive('getTotalBeforeAdditions')->andReturn(50.00); + + $orderItems = new Collection([$ticketOrderItem1, $ticketOrderItem2]); + $ticketOrderItems = new Collection([$ticketOrderItem1, $ticketOrderItem2]); + + // Create mock order + $order = Mockery::mock(OrderDomainObject::class); + $order->shouldReceive('getEventId')->andReturn($eventId); + $order->shouldReceive('getId')->andReturn($orderId); + $order->shouldReceive('getCreatedAt')->andReturn($orderDate); + $order->shouldReceive('getOrderItems')->andReturn($orderItems); + $order->shouldReceive('getTicketOrderItems')->andReturn($ticketOrderItems); + $order->shouldReceive('getPromoCodeId')->andReturn($promoCodeId); + $order->shouldReceive('getTotalGross')->andReturn(150.00); + $order->shouldReceive('getTotalBeforeAdditions')->andReturn(140.00); + $order->shouldReceive('getTotalTax')->andReturn(8.00); + $order->shouldReceive('getTotalFee')->andReturn(2.00); + + // Mock order repository to return order with relations + $this->orderRepository + ->shouldReceive('loadRelation') + ->with(OrderItemDomainObject::class) + ->andReturnSelf(); + + $this->orderRepository + ->shouldReceive('findById') + ->with($orderId) + ->andReturn($order); + + // Mock existing aggregate event statistics + $eventStatistics = Mockery::mock(EventStatisticDomainObject::class); + $eventStatistics->shouldReceive('getAttendeesRegistered')->andReturn(10); + $eventStatistics->shouldReceive('getProductsSold')->andReturn(15); + $eventStatistics->shouldReceive('getOrdersCreated')->andReturn(5); + $eventStatistics->shouldReceive('getSalesTotalGross')->andReturn(500.00); + $eventStatistics->shouldReceive('getSalesTotalBeforeAdditions')->andReturn(480.00); + $eventStatistics->shouldReceive('getTotalTax')->andReturn(15.00); + $eventStatistics->shouldReceive('getTotalFee')->andReturn(5.00); + $eventStatistics->shouldReceive('getVersion')->andReturn(5); + + // Mock existing daily event statistics + $eventDailyStatistic = Mockery::mock(EventDailyStatisticDomainObject::class); + $eventDailyStatistic->shouldReceive('getAttendeesRegistered')->andReturn(8); + $eventDailyStatistic->shouldReceive('getProductsSold')->andReturn(12); + $eventDailyStatistic->shouldReceive('getOrdersCreated')->andReturn(4); + $eventDailyStatistic->shouldReceive('getSalesTotalGross')->andReturn(400.00); + $eventDailyStatistic->shouldReceive('getSalesTotalBeforeAdditions')->andReturn(380.00); + $eventDailyStatistic->shouldReceive('getTotalTax')->andReturn(12.00); + $eventDailyStatistic->shouldReceive('getTotalFee')->andReturn(3.00); + $eventDailyStatistic->shouldReceive('getVersion')->andReturn(3); + + // Set up retrier to execute the action immediately + $this->retrier + ->shouldReceive('retry') + ->andReturnUsing(function ($callableAction) { + return $callableAction(1); + }); + + // Set up database transaction + $this->databaseManager + ->shouldReceive('transaction') + ->andReturnUsing(function ($callback) { + return $callback(); + }); + + // Expect finding aggregate statistics + $this->eventStatisticsRepository + ->shouldReceive('findFirstWhere') + ->with(['event_id' => $eventId]) + ->andReturn($eventStatistics); + + // Expect updating aggregate statistics + $this->eventStatisticsRepository + ->shouldReceive('updateWhere') + ->with( + [ + 'products_sold' => 18, // 15 + 3 + 'attendees_registered' => 13, // 10 + 3 + 'sales_total_gross' => 650.00, // 500 + 150 + 'sales_total_before_additions' => 620.00, // 480 + 140 + 'total_tax' => 23.00, // 15 + 8 + 'total_fee' => 7.00, // 5 + 2 + 'orders_created' => 6, // 5 + 1 + 'version' => 6, // 5 + 1 + ], + [ + 'event_id' => $eventId, + 'version' => 5, + ] + ) + ->andReturn(1); + + // Expect finding daily statistics + $this->eventDailyStatisticRepository + ->shouldReceive('findFirstWhere') + ->with([ + 'event_id' => $eventId, + 'date' => '2024-01-15', + ]) + ->andReturn($eventDailyStatistic); + + // Expect updating daily statistics + $this->eventDailyStatisticRepository + ->shouldReceive('updateWhere') + ->with( + [ + 'products_sold' => 15, // 12 + 3 + 'attendees_registered' => 11, // 8 + 3 + 'sales_total_gross' => 550.00, // 400 + 150 + 'sales_total_before_additions' => 520.00, // 380 + 140 + 'total_tax' => 20.00, // 12 + 8 + 'total_fee' => 5.00, // 3 + 2 + 'orders_created' => 5, // 4 + 1 + 'version' => 4, // 3 + 1 + ], + [ + 'event_id' => $eventId, + 'date' => '2024-01-15', + 'version' => 3, + ] + ) + ->andReturn(1); + + // Expect incrementing promo code usage + $this->promoCodeRepository + ->shouldReceive('increment') + ->with($promoCodeId, PromoCodeDomainObjectAbstract::ORDER_USAGE_COUNT) + ->once(); + + $this->promoCodeRepository + ->shouldReceive('increment') + ->with($promoCodeId, PromoCodeDomainObjectAbstract::ATTENDEE_USAGE_COUNT, 3) + ->once(); + + // Expect incrementing product statistics + $this->productRepository + ->shouldReceive('increment') + ->with(1, ProductDomainObjectAbstract::SALES_VOLUME, 100.00) + ->once(); + + $this->productRepository + ->shouldReceive('increment') + ->with(2, ProductDomainObjectAbstract::SALES_VOLUME, 50.00) + ->once(); + + // Expect logging + $this->logger->shouldReceive('info')->atLeast()->once(); + + // Execute + $this->service->incrementForOrder($order); + + + $this->assertTrue(true); + } + + public function testIncrementForOrderCreatesNewStatistics(): void + { + $eventId = 1; + $orderId = 123; + $orderDate = '2024-01-15 10:30:00'; + + // Create mock order item + $orderItem = Mockery::mock(OrderItemDomainObject::class); + $orderItem->shouldReceive('getQuantity')->andReturn(2); + $orderItem->shouldReceive('getProductId')->andReturn(1); + $orderItem->shouldReceive('getTotalBeforeAdditions')->andReturn(100.00); + + $orderItems = new Collection([$orderItem]); + $ticketOrderItems = new Collection([$orderItem]); + + // Create mock order + $order = Mockery::mock(OrderDomainObject::class); + $order->shouldReceive('getEventId')->andReturn($eventId); + $order->shouldReceive('getId')->andReturn($orderId); + $order->shouldReceive('getCreatedAt')->andReturn($orderDate); + $order->shouldReceive('getOrderItems')->andReturn($orderItems); + $order->shouldReceive('getTicketOrderItems')->andReturn($ticketOrderItems); + $order->shouldReceive('getPromoCodeId')->andReturnNull(); + $order->shouldReceive('getTotalGross')->andReturn(100.00); + $order->shouldReceive('getTotalBeforeAdditions')->andReturn(95.00); + $order->shouldReceive('getTotalTax')->andReturn(4.00); + $order->shouldReceive('getTotalFee')->andReturn(1.00); + + // Mock order repository + $this->orderRepository + ->shouldReceive('loadRelation') + ->with(OrderItemDomainObject::class) + ->andReturnSelf(); + + $this->orderRepository + ->shouldReceive('findById') + ->with($orderId) + ->andReturn($order); + + // Set up retrier + $this->retrier + ->shouldReceive('retry') + ->andReturnUsing(function ($callableAction) { + return $callableAction(1); + }); + + // Set up database transaction + $this->databaseManager + ->shouldReceive('transaction') + ->andReturnUsing(function ($callback) { + return $callback(); + }); + + // Expect aggregate statistics not found, so create new + $this->eventStatisticsRepository + ->shouldReceive('findFirstWhere') + ->with(['event_id' => $eventId]) + ->andReturnNull(); + + $this->eventStatisticsRepository + ->shouldReceive('create') + ->with([ + 'event_id' => $eventId, + 'products_sold' => 2, + 'attendees_registered' => 2, + 'sales_total_gross' => 100.00, + 'sales_total_before_additions' => 95.00, + 'total_tax' => 4.00, + 'total_fee' => 1.00, + 'orders_created' => 1, + 'orders_cancelled' => 0, + ]) + ->once(); + + // Expect daily statistics not found, so create new + $this->eventDailyStatisticRepository + ->shouldReceive('findFirstWhere') + ->with([ + 'event_id' => $eventId, + 'date' => '2024-01-15', + ]) + ->andReturnNull(); + + $this->eventDailyStatisticRepository + ->shouldReceive('create') + ->with([ + 'event_id' => $eventId, + 'date' => '2024-01-15', + 'products_sold' => 2, + 'attendees_registered' => 2, + 'sales_total_gross' => 100.00, + 'sales_total_before_additions' => 95.00, + 'total_tax' => 4.00, + 'total_fee' => 1.00, + 'orders_created' => 1, + 'orders_cancelled' => 0, + ]) + ->once(); + + // Expect incrementing product statistics + $this->productRepository + ->shouldReceive('increment') + ->with(1, ProductDomainObjectAbstract::SALES_VOLUME, 100.00) + ->once(); + + // Expect logging + $this->logger->shouldReceive('info')->atLeast()->once(); + + // Execute + $this->service->incrementForOrder($order); + + + $this->assertTrue(true); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsRefundServiceTest.php b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsRefundServiceTest.php new file mode 100644 index 0000000000..5e41a74713 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsRefundServiceTest.php @@ -0,0 +1,330 @@ +eventStatisticsRepository = Mockery::mock(EventStatisticRepositoryInterface::class); + $this->eventDailyStatisticRepository = Mockery::mock(EventDailyStatisticRepositoryInterface::class); + $this->logger = Mockery::mock(LoggerInterface::class); + + $this->service = new EventStatisticsRefundService( + $this->eventStatisticsRepository, + $this->eventDailyStatisticRepository, + $this->logger + ); + } + + public function testUpdateForRefundFullAmount(): void + { + $eventId = 1; + $orderId = 123; + $orderDate = '2024-01-15 10:30:00'; + $currency = 'USD'; + + // Create mock order + $order = Mockery::mock(OrderDomainObject::class); + $order->shouldReceive('getEventId')->andReturn($eventId); + $order->shouldReceive('getId')->andReturn($orderId); + $order->shouldReceive('getCreatedAt')->andReturn($orderDate); + $order->shouldReceive('getCurrency')->andReturn($currency); + $order->shouldReceive('getTotalGross')->andReturn(100.00); + $order->shouldReceive('getTotalTax')->andReturn(8.00); + $order->shouldReceive('getTotalFee')->andReturn(2.00); + + // Create refund amount (full refund) + $refundAmount = MoneyValue::fromFloat(100.00, $currency); + + // Mock aggregate event statistics + $eventStatistics = Mockery::mock(EventStatisticDomainObject::class); + $eventStatistics->shouldReceive('getSalesTotalGross')->andReturn(1000.00); + $eventStatistics->shouldReceive('getTotalRefunded')->andReturn(50.00); + $eventStatistics->shouldReceive('getTotalTax')->andReturn(80.00); + $eventStatistics->shouldReceive('getTotalFee')->andReturn(20.00); + + // Mock daily event statistics + $eventDailyStatistic = Mockery::mock(EventDailyStatisticDomainObject::class); + $eventDailyStatistic->shouldReceive('getSalesTotalGross')->andReturn(500.00); + $eventDailyStatistic->shouldReceive('getTotalRefunded')->andReturn(25.00); + $eventDailyStatistic->shouldReceive('getTotalTax')->andReturn(40.00); + $eventDailyStatistic->shouldReceive('getTotalFee')->andReturn(10.00); + + // Expect finding aggregate statistics + $this->eventStatisticsRepository + ->shouldReceive('findFirstWhere') + ->with(['event_id' => $eventId]) + ->andReturn($eventStatistics); + + // Expect updating aggregate statistics (full refund = 100% proportion) + $this->eventStatisticsRepository + ->shouldReceive('updateWhere') + ->with( + [ + 'sales_total_gross' => 900.00, // 1000 - 100 + 'total_refunded' => 150.00, // 50 + 100 + 'total_tax' => 72.00, // 80 - 8 (100% of order tax) + 'total_fee' => 18.00, // 20 - 2 (100% of order fee) + ], + ['event_id' => $eventId] + ) + ->once(); + + // Expect finding daily statistics + $this->eventDailyStatisticRepository + ->shouldReceive('findFirstWhere') + ->with([ + 'event_id' => $eventId, + 'date' => '2024-01-15', + ]) + ->andReturn($eventDailyStatistic); + + // Expect updating daily statistics + $this->eventDailyStatisticRepository + ->shouldReceive('updateWhere') + ->with( + [ + 'sales_total_gross' => 400.00, // 500 - 100 + 'total_refunded' => 125.00, // 25 + 100 + 'total_tax' => 32.00, // 40 - 8 + 'total_fee' => 8.00, // 10 - 2 + ], + [ + 'event_id' => $eventId, + 'date' => '2024-01-15', + ] + ) + ->once(); + + // Expect logging + $this->logger->shouldReceive('info')->twice(); + + // Execute + $this->service->updateForRefund($order, $refundAmount); + + + $this->assertTrue(true); + } + + public function testUpdateForRefundPartialAmount(): void + { + $eventId = 1; + $orderId = 123; + $orderDate = '2024-01-15 10:30:00'; + $currency = 'USD'; + + // Create mock order + $order = Mockery::mock(OrderDomainObject::class); + $order->shouldReceive('getEventId')->andReturn($eventId); + $order->shouldReceive('getId')->andReturn($orderId); + $order->shouldReceive('getCreatedAt')->andReturn($orderDate); + $order->shouldReceive('getCurrency')->andReturn($currency); + $order->shouldReceive('getTotalGross')->andReturn(100.00); + $order->shouldReceive('getTotalTax')->andReturn(8.00); + $order->shouldReceive('getTotalFee')->andReturn(2.00); + + // Create refund amount (50% partial refund) + $refundAmount = MoneyValue::fromFloat(50.00, $currency); + + // Mock aggregate event statistics + $eventStatistics = Mockery::mock(EventStatisticDomainObject::class); + $eventStatistics->shouldReceive('getSalesTotalGross')->andReturn(1000.00); + $eventStatistics->shouldReceive('getTotalRefunded')->andReturn(50.00); + $eventStatistics->shouldReceive('getTotalTax')->andReturn(80.00); + $eventStatistics->shouldReceive('getTotalFee')->andReturn(20.00); + + // Mock daily event statistics + $eventDailyStatistic = Mockery::mock(EventDailyStatisticDomainObject::class); + $eventDailyStatistic->shouldReceive('getSalesTotalGross')->andReturn(500.00); + $eventDailyStatistic->shouldReceive('getTotalRefunded')->andReturn(25.00); + $eventDailyStatistic->shouldReceive('getTotalTax')->andReturn(40.00); + $eventDailyStatistic->shouldReceive('getTotalFee')->andReturn(10.00); + + // Expect finding aggregate statistics + $this->eventStatisticsRepository + ->shouldReceive('findFirstWhere') + ->with(['event_id' => $eventId]) + ->andReturn($eventStatistics); + + // Expect updating aggregate statistics (50% refund = 0.5 proportion) + $this->eventStatisticsRepository + ->shouldReceive('updateWhere') + ->with( + [ + 'sales_total_gross' => 950.00, // 1000 - 50 + 'total_refunded' => 100.00, // 50 + 50 + 'total_tax' => 76.00, // 80 - 4 (50% of order tax) + 'total_fee' => 19.00, // 20 - 1 (50% of order fee) + ], + ['event_id' => $eventId] + ) + ->once(); + + // Expect finding daily statistics + $this->eventDailyStatisticRepository + ->shouldReceive('findFirstWhere') + ->with([ + 'event_id' => $eventId, + 'date' => '2024-01-15', + ]) + ->andReturn($eventDailyStatistic); + + // Expect updating daily statistics + $this->eventDailyStatisticRepository + ->shouldReceive('updateWhere') + ->with( + [ + 'sales_total_gross' => 450.00, // 500 - 50 + 'total_refunded' => 75.00, // 25 + 50 + 'total_tax' => 36.00, // 40 - 4 + 'total_fee' => 9.00, // 10 - 1 + ], + [ + 'event_id' => $eventId, + 'date' => '2024-01-15', + ] + ) + ->once(); + + // Expect logging + $this->logger->shouldReceive('info')->twice(); + + // Execute + $this->service->updateForRefund($order, $refundAmount); + + + $this->assertTrue(true); + } + + public function testThrowsExceptionWhenAggregateStatisticsNotFound(): void + { + $eventId = 1; + $orderId = 123; + $currency = 'USD'; + + // Create mock order + $order = Mockery::mock(OrderDomainObject::class); + $order->shouldReceive('getEventId')->andReturn($eventId); + $order->shouldReceive('getId')->andReturn($orderId); + $order->shouldReceive('getCurrency')->andReturn($currency); + + // Create refund amount + $refundAmount = MoneyValue::fromFloat(50.00, $currency); + + // Expect aggregate statistics not found + $this->eventStatisticsRepository + ->shouldReceive('findFirstWhere') + ->with(['event_id' => $eventId]) + ->andReturnNull(); + + // Expect exception + $this->expectException(ResourceNotFoundException::class); + $this->expectExceptionMessage("Event statistics not found for event {$eventId}"); + + // Execute + $this->service->updateForRefund($order, $refundAmount); + + + $this->assertTrue(true); + } + + public function testLogsWarningWhenDailyStatisticsNotFound(): void + { + $eventId = 1; + $orderId = 123; + $orderDate = '2024-01-15 10:30:00'; + $currency = 'USD'; + + // Create mock order + $order = Mockery::mock(OrderDomainObject::class); + $order->shouldReceive('getEventId')->andReturn($eventId); + $order->shouldReceive('getId')->andReturn($orderId); + $order->shouldReceive('getCreatedAt')->andReturn($orderDate); + $order->shouldReceive('getCurrency')->andReturn($currency); + $order->shouldReceive('getTotalGross')->andReturn(100.00); + $order->shouldReceive('getTotalTax')->andReturn(8.00); + $order->shouldReceive('getTotalFee')->andReturn(2.00); + + // Create refund amount + $refundAmount = MoneyValue::fromFloat(50.00, $currency); + + // Mock aggregate event statistics + $eventStatistics = Mockery::mock(EventStatisticDomainObject::class); + $eventStatistics->shouldReceive('getSalesTotalGross')->andReturn(1000.00); + $eventStatistics->shouldReceive('getTotalRefunded')->andReturn(50.00); + $eventStatistics->shouldReceive('getTotalTax')->andReturn(80.00); + $eventStatistics->shouldReceive('getTotalFee')->andReturn(20.00); + + // Expect finding aggregate statistics + $this->eventStatisticsRepository + ->shouldReceive('findFirstWhere') + ->with(['event_id' => $eventId]) + ->andReturn($eventStatistics); + + // Expect updating aggregate statistics + $this->eventStatisticsRepository + ->shouldReceive('updateWhere') + ->once(); + + // Expect daily statistics not found + $this->eventDailyStatisticRepository + ->shouldReceive('findFirstWhere') + ->with([ + 'event_id' => $eventId, + 'date' => '2024-01-15', + ]) + ->andReturnNull(); + + // Expect warning log for missing daily statistics + $this->logger + ->shouldReceive('warning') + ->with( + 'Event daily statistics not found for refund', + [ + 'event_id' => $eventId, + 'date' => '2024-01-15', + 'order_id' => $orderId, + ] + ) + ->once(); + + // Expect info log for aggregate update + $this->logger->shouldReceive('info')->once(); + + // Should not attempt to update daily statistics + $this->eventDailyStatisticRepository->shouldNotReceive('updateWhere'); + + // Execute + $this->service->updateForRefund($order, $refundAmount); + + + $this->assertTrue(true); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php index e92693a3b1..ece4664ba2 100644 --- a/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php @@ -17,6 +17,7 @@ use HiEvents\Services\Infrastructure\DomainEvents\DomainEventDispatcherService; use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; use HiEvents\Services\Infrastructure\DomainEvents\Events\OrderEvent; +use HiEvents\Services\Domain\EventStatistics\EventStatisticsCancellationService; use Illuminate\Contracts\Mail\Mailer; use Illuminate\Database\DatabaseManager; use Illuminate\Support\Collection; @@ -34,6 +35,7 @@ class OrderCancelServiceTest extends TestCase private ProductQuantityUpdateService $productQuantityService; private OrderCancelService $service; private DomainEventDispatcherService $domainEventDispatcherService; + private EventStatisticsCancellationService $eventStatisticsCancellationService; protected function setUp(): void { @@ -46,6 +48,7 @@ protected function setUp(): void $this->databaseManager = m::mock(DatabaseManager::class); $this->productQuantityService = m::mock(ProductQuantityUpdateService::class); $this->domainEventDispatcherService = m::mock(DomainEventDispatcherService::class); + $this->eventStatisticsCancellationService = m::mock(EventStatisticsCancellationService::class); $this->service = new OrderCancelService( mailer: $this->mailer, @@ -55,6 +58,7 @@ protected function setUp(): void databaseManager: $this->databaseManager, productQuantityService: $this->productQuantityService, domainEventDispatcherService: $this->domainEventDispatcherService, + eventStatisticsCancellationService: $this->eventStatisticsCancellationService, ); } @@ -87,6 +91,10 @@ public function testCancelOrder(): void $this->orderRepository->shouldReceive('updateWhere')->once(); + $this->eventStatisticsCancellationService->shouldReceive('decrementForCancelledOrder') + ->once() + ->with($order); + $event = new EventDomainObject(); $event->setEventSettings(new EventSettingDomainObject()); $event->setOrganizer(new OrganizerDomainObject()); @@ -161,6 +169,10 @@ public function testCancelOrderAwaitingOfflinePayment(): void $this->orderRepository->shouldReceive('updateWhere')->once(); + $this->eventStatisticsCancellationService->shouldReceive('decrementForCancelledOrder') + ->once() + ->with($order); + $event = new EventDomainObject(); $event->setEventSettings(new EventSettingDomainObject()); $event->setOrganizer(new OrganizerDomainObject()); diff --git a/backend/tests/Unit/Services/Domain/Payment/Stripe/StripeAccountSyncServiceTest.php b/backend/tests/Unit/Services/Domain/Payment/Stripe/StripeAccountSyncServiceTest.php new file mode 100644 index 0000000000..2e61804974 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Payment/Stripe/StripeAccountSyncServiceTest.php @@ -0,0 +1,86 @@ +logger = m::mock(LoggerInterface::class); + $this->accountRepository = m::mock(AccountRepositoryInterface::class); + $this->accountStripePlatformRepository = m::mock(AccountStripePlatformRepositoryInterface::class); + + $this->service = new StripeAccountSyncService( + $this->logger, + $this->accountRepository, + $this->accountStripePlatformRepository, + ); + } + + public function testIsStripeAccountCompleteReturnsTrueWhenBothEnabled(): void + { + $stripeAccount = new Account(); + $stripeAccount->charges_enabled = true; + $stripeAccount->payouts_enabled = true; + + $result = $this->service->isStripeAccountComplete($stripeAccount); + + $this->assertTrue($result); + } + + public function testIsStripeAccountCompleteReturnsFalseWhenChargesDisabled(): void + { + $stripeAccount = new Account(); + $stripeAccount->charges_enabled = false; + $stripeAccount->payouts_enabled = true; + + $result = $this->service->isStripeAccountComplete($stripeAccount); + + $this->assertFalse($result); + } + + public function testIsStripeAccountCompleteReturnsFalseWhenPayoutsDisabled(): void + { + $stripeAccount = new Account(); + $stripeAccount->charges_enabled = true; + $stripeAccount->payouts_enabled = false; + + $result = $this->service->isStripeAccountComplete($stripeAccount); + + $this->assertFalse($result); + } + + public function testIsStripeAccountCompleteReturnsFalseWhenBothDisabled(): void + { + $stripeAccount = new Account(); + $stripeAccount->charges_enabled = false; + $stripeAccount->payouts_enabled = false; + + $result = $this->service->isStripeAccountComplete($stripeAccount); + + $this->assertFalse($result); + } + + protected function tearDown(): void + { + m::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Infrastructure/Email/LiquidTemplateRendererTest.php b/backend/tests/Unit/Services/Infrastructure/Email/LiquidTemplateRendererTest.php new file mode 100644 index 0000000000..11064ab4bc --- /dev/null +++ b/backend/tests/Unit/Services/Infrastructure/Email/LiquidTemplateRendererTest.php @@ -0,0 +1,167 @@ +renderer = new LiquidTemplateRenderer(); + } + + public function test_can_render_simple_template_with_context(): void + { + $template = 'Hello {{ customer.name }}!'; + $context = [ + 'customer' => [ + 'name' => 'John Doe' + ] + ]; + + $result = $this->renderer->render($template, $context); + + $this->assertEquals('Hello John Doe!', $result); + } + + public function test_can_render_complex_template_with_nested_context(): void + { + $template = 'Order #{{ order.order_code }} for {{ event.title }} - Total: {{ order.total_gross_formatted }}'; + $context = [ + 'order' => [ + 'order_code' => 'ORD-123', + 'total_gross_formatted' => '$49.99' + ], + 'event' => [ + 'title' => 'Amazing Concert' + ] + ]; + + $result = $this->renderer->render($template, $context); + + $this->assertEquals('Order #ORD-123 for Amazing Concert - Total: $49.99', $result); + } + + public function test_can_render_template_with_loops(): void + { + $template = 'Items:{% for item in order.items %} {{ item.title }} ({{ item.quantity }}){% endfor %}'; + $context = [ + 'order' => [ + 'items' => [ + ['title' => 'General Admission', 'quantity' => 2], + ['title' => 'VIP Pass', 'quantity' => 1] + ] + ] + ]; + + $result = $this->renderer->render($template, $context); + + $this->assertEquals('Items: General Admission (2) VIP Pass (1)', $result); + } + + public function test_can_render_template_with_conditionals(): void + { + $template = '{% if customer.name %}Hello {{ customer.name }}{% else %}Hello Guest{% endif %}'; + + $contextWithName = ['customer' => ['name' => 'Jane']]; + $contextWithoutName = ['customer' => []]; + + $resultWithName = $this->renderer->render($template, $contextWithName); + $resultWithoutName = $this->renderer->render($template, $contextWithoutName); + + $this->assertEquals('Hello Jane', $resultWithName); + $this->assertEquals('Hello Guest', $resultWithoutName); + } + + public function test_validates_correct_template_syntax(): void + { + $validTemplate = 'Hello {{ customer.name }}!'; + + $result = $this->renderer->validate($validTemplate); + + $this->assertTrue($result); + } + + public function test_validates_incorrect_template_syntax(): void + { + $invalidTemplate = 'Hello {% if %}'; // Invalid if syntax + + $result = $this->renderer->validate($invalidTemplate); + + $this->assertFalse($result); + } + + public function test_returns_available_tokens_for_order_confirmation(): void + { + $tokens = $this->renderer->getAvailableTokens(EmailTemplateType::ORDER_CONFIRMATION); + + $this->assertIsArray($tokens); + $this->assertNotEmpty($tokens); + + // Check that some expected tokens are present with new dot notation + $tokenStrings = array_column($tokens, 'token'); + $this->assertContains('{{ order.number }}', $tokenStrings); + $this->assertContains('{{ event.title }}', $tokenStrings); + $this->assertContains('{{ organizer.name }}', $tokenStrings); + } + + public function test_returns_available_tokens_for_attendee_ticket(): void + { + $tokens = $this->renderer->getAvailableTokens(EmailTemplateType::ATTENDEE_TICKET); + + $this->assertIsArray($tokens); + $this->assertNotEmpty($tokens); + + // Check that some expected tokens are present with new dot notation + $tokenStrings = array_column($tokens, 'token'); + $this->assertContains('{{ attendee.name }}', $tokenStrings); + $this->assertContains('{{ ticket.name }}', $tokenStrings); + $this->assertContains('{{ event.title }}', $tokenStrings); + $this->assertContains('{{ ticket.url }}', $tokenStrings); + } + + public function test_token_structure_contains_required_fields(): void + { + $tokens = $this->renderer->getAvailableTokens(EmailTemplateType::ORDER_CONFIRMATION); + + foreach ($tokens as $token) { + $this->assertArrayHasKey('token', $token); + $this->assertArrayHasKey('description', $token); + $this->assertArrayHasKey('example', $token); + + $this->assertIsString($token['token']); + $this->assertIsString($token['description']); + $this->assertIsString($token['example']); + } + } + + public function test_handles_missing_context_gracefully(): void + { + $template = 'Hello {{ customer.name }}!'; + $context = []; // Empty context + + $result = $this->renderer->render($template, $context); + + // Liquid typically renders undefined variables as empty strings + $this->assertEquals('Hello !', $result); + } + + public function test_renders_html_content_as_expected(): void + { + $template = 'Message: {{ message }}'; + $context = [ + 'message' => '' + ]; + + $result = $this->renderer->render($template, $context); + + // Test that the template renders the content + $this->assertStringContainsString('Message: ', $result); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/Infrastructure/Stripe/StripeClientFactoryTest.php b/backend/tests/Unit/Services/Infrastructure/Stripe/StripeClientFactoryTest.php new file mode 100644 index 0000000000..56845cb913 --- /dev/null +++ b/backend/tests/Unit/Services/Infrastructure/Stripe/StripeClientFactoryTest.php @@ -0,0 +1,105 @@ +mockConfigService = Mockery::mock(StripeConfigurationService::class); + $this->factory = new StripeClientFactory($this->mockConfigService); + } + + public function test_create_for_platform_creates_client_with_default_key(): void + { + $this->mockConfigService + ->shouldReceive('getSecretKey') + ->with(null) + ->once() + ->andReturn('sk_test_default'); + + $client = $this->factory->createForPlatform(); + + $this->assertInstanceOf(StripeClient::class, $client); + } + + public function test_create_for_platform_creates_client_with_canada_key(): void + { + $this->mockConfigService + ->shouldReceive('getSecretKey') + ->with(StripePlatform::CANADA) + ->once() + ->andReturn('sk_test_canada'); + + $client = $this->factory->createForPlatform(StripePlatform::CANADA); + + $this->assertInstanceOf(StripeClient::class, $client); + } + + public function test_create_for_platform_creates_client_with_ireland_key(): void + { + $this->mockConfigService + ->shouldReceive('getSecretKey') + ->with(StripePlatform::IRELAND) + ->once() + ->andReturn('sk_test_ireland'); + + $client = $this->factory->createForPlatform(StripePlatform::IRELAND); + + $this->assertInstanceOf(StripeClient::class, $client); + } + + public function test_create_for_platform_throws_exception_when_no_secret_key(): void + { + $this->mockConfigService + ->shouldReceive('getSecretKey') + ->with(null) + ->once() + ->andReturn(''); + + $this->expectException(StripeClientConfigurationException::class); + $this->expectExceptionMessage('Stripe secret key not configured for platform: default'); + + $this->factory->createForPlatform(); + } + + public function test_create_for_platform_throws_exception_for_canada_platform_missing_key(): void + { + $this->mockConfigService + ->shouldReceive('getSecretKey') + ->with(StripePlatform::CANADA) + ->once() + ->andReturn(''); + + $this->expectException(StripeClientConfigurationException::class); + $this->expectExceptionMessage('Stripe secret key not configured for platform: ca'); + + $this->factory->createForPlatform(StripePlatform::CANADA); + } + + public function test_create_for_platform_throws_exception_for_ireland_platform_missing_key(): void + { + $this->mockConfigService + ->shouldReceive('getSecretKey') + ->with(StripePlatform::IRELAND) + ->once() + ->andReturn(''); + + $this->expectException(StripeClientConfigurationException::class); + $this->expectExceptionMessage('Stripe secret key not configured for platform: ie'); + + $this->factory->createForPlatform(StripePlatform::IRELAND); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/Infrastructure/Stripe/StripeConfigurationServiceTest.php b/backend/tests/Unit/Services/Infrastructure/Stripe/StripeConfigurationServiceTest.php new file mode 100644 index 0000000000..cfbdf2b0a3 --- /dev/null +++ b/backend/tests/Unit/Services/Infrastructure/Stripe/StripeConfigurationServiceTest.php @@ -0,0 +1,162 @@ +service = new StripeConfigurationService(); + } + + public function test_get_secret_key_returns_default_when_no_platform(): void + { + config(['services.stripe.secret_key' => 'sk_default']); + + $result = $this->service->getSecretKey(); + + $this->assertEquals('sk_default', $result); + } + + public function test_get_secret_key_returns_canada_platform_key(): void + { + config([ + 'services.stripe.secret_key' => 'sk_default', + 'services.stripe.ca_secret_key' => 'sk_canada' + ]); + + $result = $this->service->getSecretKey(StripePlatform::CANADA); + + $this->assertEquals('sk_canada', $result); + } + + public function test_get_secret_key_returns_ireland_platform_key(): void + { + config([ + 'services.stripe.secret_key' => 'sk_default', + 'services.stripe.ie_secret_key' => 'sk_ireland' + ]); + + $result = $this->service->getSecretKey(StripePlatform::IRELAND); + + $this->assertEquals('sk_ireland', $result); + } + + public function test_get_secret_key_returns_null_when_no_keys_configured(): void + { + // Clear all configuration + config([ + 'services.stripe.secret_key' => null, + 'services.stripe.ca_secret_key' => null, + 'services.stripe.ie_secret_key' => null + ]); + + $result = $this->service->getSecretKey(); + + $this->assertNull($result); + } + + public function test_get_public_key_returns_correct_platform_keys(): void + { + config([ + 'services.stripe.public_key' => 'pk_default', + 'services.stripe.ca_public_key' => 'pk_canada', + 'services.stripe.ie_public_key' => 'pk_ireland' + ]); + + $this->assertEquals('pk_default', $this->service->getPublicKey()); + $this->assertEquals('pk_canada', $this->service->getPublicKey(StripePlatform::CANADA)); + $this->assertEquals('pk_ireland', $this->service->getPublicKey(StripePlatform::IRELAND)); + } + + public function test_get_all_webhook_secrets_includes_all_platforms(): void + { + config([ + 'services.stripe.webhook_secret' => 'whsec_default', + 'services.stripe.ca_webhook_secret' => 'whsec_canada', + 'services.stripe.ie_webhook_secret' => 'whsec_ireland' + ]); + + $result = $this->service->getAllWebhookSecrets(); + + $this->assertEquals('whsec_default', $result['default']); + $this->assertEquals('whsec_canada', $result['ca']); + $this->assertEquals('whsec_ireland', $result['ie']); + } + + public function test_get_primary_platform_returns_correct_enum(): void + { + config(['services.stripe.primary_platform' => 'ie']); + + $result = $this->service->getPrimaryPlatform(); + + $this->assertEquals(StripePlatform::IRELAND, $result); + } + + public function test_get_primary_platform_returns_null_when_not_configured(): void + { + config(['services.stripe.primary_platform' => null]); + + $result = $this->service->getPrimaryPlatform(); + + $this->assertNull($result); + } + + public function test_get_primary_platform_returns_null_for_invalid_platform(): void + { + config(['services.stripe.primary_platform' => 'invalid']); + + $result = $this->service->getPrimaryPlatform(); + + $this->assertNull($result); + } + + public function test_get_all_webhook_secrets_returns_filtered_secrets(): void + { + config([ + 'services.stripe.webhook_secret' => 'whsec_default', + 'services.stripe.ca_webhook_secret' => 'whsec_canada', + 'services.stripe.ie_webhook_secret' => null + ]); + + $result = $this->service->getAllWebhookSecrets(); + + $expected = [ + 'default' => 'whsec_default', + 'ca' => 'whsec_canada' + ]; + + $this->assertEquals($expected, $result); + } + + public function test_get_all_webhook_secrets_orders_primary_platform_first(): void + { + config([ + 'services.stripe.webhook_secret' => 'whsec_default', + 'services.stripe.ca_webhook_secret' => 'whsec_canada', + 'services.stripe.ie_webhook_secret' => 'whsec_ireland', + 'services.stripe.primary_platform' => 'ie' + ]); + + $result = $this->service->getAllWebhookSecrets(); + + $keys = array_keys($result); + $this->assertEquals('ie', $keys[0], 'Primary platform should be first'); + } + + public function test_get_primary_platform_handles_string_conversion(): void + { + config(['services.stripe.primary_platform' => 'ca']); + + $result = $this->service->getPrimaryPlatform(); + + $this->assertEquals(StripePlatform::CANADA, $result); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/Infrastructure/Utlitiy/Retry/RetrierTest.php b/backend/tests/Unit/Services/Infrastructure/Utlitiy/Retry/RetrierTest.php new file mode 100644 index 0000000000..c76ff54edb --- /dev/null +++ b/backend/tests/Unit/Services/Infrastructure/Utlitiy/Retry/RetrierTest.php @@ -0,0 +1,82 @@ +retry($operation, $maxAttempts, 1); + + $this->assertEquals("Success", $result); + $this->assertEquals($maxAttempts, $attempts); + } + + public function testFailsAfterMaxAttempts(): void + { + $attempts = 0; + $maxAttempts = 3; + + $operation = function () use (&$attempts) { + $attempts++; + throw new Exception("Persistent failure"); + }; + + $retrier = new Retrier(); + + $this->expectException(Exception::class); + $this->expectExceptionMessage("Persistent failure"); + + try { + $retrier->retry($operation, $maxAttempts, 1); + } finally { + $this->assertEquals($maxAttempts, $attempts); + } + } + + public function testOnFailureCallbackIsCalled(): void + { + $attempts = 0; + $maxAttempts = 3; + $onFailureCalled = false; + + $operation = function () use (&$attempts) { + $attempts++; + throw new Exception("Persistent failure"); + }; + + $onFailure = function (int $attempt, Exception $e) use (&$onFailureCalled, $maxAttempts) { + $onFailureCalled = true; + $this->assertEquals($maxAttempts, $attempt); + $this->assertEquals("Persistent failure", $e->getMessage()); + }; + + $retrier = new Retrier(); + + $this->expectException(Exception::class); + $this->expectExceptionMessage("Persistent failure"); + + try { + $retrier->retry($operation, $maxAttempts, 1, onFailure: $onFailure); + } finally { + $this->assertEquals($maxAttempts, $attempts); + $this->assertTrue($onFailureCalled); + } + } +} diff --git a/frontend/lingui.config.ts b/frontend/lingui.config.ts index 250633a3a2..8dd41e3d4d 100644 --- a/frontend/lingui.config.ts +++ b/frontend/lingui.config.ts @@ -12,6 +12,7 @@ const config: LinguiConfig = { "de", // German "pt", // Portuguese (Portugal) "vi", // Vietnamese + "tr", // Turkish "it", // Italian // "pl", // Polish diff --git a/frontend/package.json b/frontend/package.json index 8d2f9953ef..b62938650d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -45,10 +45,12 @@ "@tiptap/extension-color": "^2.11.7", "@tiptap/extension-image": "^2.11.5", "@tiptap/extension-link": "^2.1.13", + "@tiptap/extension-task-item": "^3.3.0", + "@tiptap/extension-task-list": "^3.3.0", "@tiptap/extension-text-align": "^2.1.13", "@tiptap/extension-text-style": "^2.12.0", "@tiptap/extension-underline": "^2.1.13", - "@tiptap/pm": "^2.12.0", + "@tiptap/pm": "^3.3.0", "@tiptap/react": "^2.1.13", "@tiptap/starter-kit": "^2.1.13", "axios": "^1.4.0", diff --git a/frontend/scripts/list_untranslated_strings.sh b/frontend/scripts/list_untranslated_strings.sh index e0dea61d42..158a2a5a5b 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/vi.po" +poFile="../src/locales/pt.po" if [ -f "$poFile" ]; then echo "Checking file: $poFile" @@ -12,7 +12,20 @@ if [ -f "$poFile" ]; then BEGIN { RS=""; FS="\n" } { msgid = ""; msgstr = ""; references = ""; - in_msgid = 0; in_msgstr = 0; + in_msgid = 0; in_msgstr = 0; is_obsolete = 0; + + # Check if this entry is obsolete (contains #~ lines) + for (i = 1; i <= NF; i++) { + if ($i ~ /^#~/) { + is_obsolete = 1; + break; + } + } + + # Skip obsolete entries + if (is_obsolete) { + next; + } for (i = 1; i <= NF; i++) { if ($i ~ /^msgid "/) { @@ -34,6 +47,11 @@ if [ -f "$poFile" ]; then gsub(/\n/, "", msgid); gsub(/\n/, "", msgstr); + # Skip the file header entry (empty msgid) + if (msgid == "msgid \"\"") { + next; + } + if (msgstr == "msgstr \"\"") { if (references != "") { print references; @@ -45,4 +63,4 @@ if [ -f "$poFile" ]; then ' "$poFile" else echo "File not found: $poFile" -fi +fi \ No newline at end of file diff --git a/frontend/src/api/account.client.ts b/frontend/src/api/account.client.ts index 2073ecd36e..848c398612 100644 --- a/frontend/src/api/account.client.ts +++ b/frontend/src/api/account.client.ts @@ -1,5 +1,5 @@ import {api} from "./client.ts"; -import {Account, GenericDataResponse, IdParam, User} from "../types.ts"; +import {Account, GenericDataResponse, IdParam, User, StripeConnectAccountsResponse} from "../types.ts"; interface CreateAccountRequest { first_name: string; @@ -21,8 +21,14 @@ export const accountClient = { const response = await api.put>('accounts', account); return response.data; }, - getStripeConnectDetails: async (accountId: IdParam) => { - const response = await api.post>(`accounts/${accountId}/stripe/connect`); + getStripeConnectDetails: async (accountId: IdParam, platform?: string) => { + const response = await api.post>(`accounts/${accountId}/stripe/connect`, { + platform + }); + return response.data; + }, + getStripeConnectAccounts: async (accountId: IdParam) => { + const response = await api.get>(`accounts/${accountId}/stripe/connect_accounts`); return response.data; } } \ No newline at end of file diff --git a/frontend/src/api/email-template.client.ts b/frontend/src/api/email-template.client.ts new file mode 100644 index 0000000000..d5c2fbab35 --- /dev/null +++ b/frontend/src/api/email-template.client.ts @@ -0,0 +1,93 @@ +import {api} from "./client"; +import { + EmailTemplate, + EmailTemplateToken, + EmailTemplatePreview, + CreateEmailTemplateRequest, + UpdateEmailTemplateRequest, + PreviewEmailTemplateRequest, + GenericDataResponse, + IdParam, + EmailTemplateType, + DefaultEmailTemplate +} from "../types"; + +export const emailTemplateClient = { + // Organizer-level templates + getByOrganizer: async (organizerId: IdParam, params?: { template_type?: EmailTemplateType, include_inactive?: boolean }) => { + const queryParams = new URLSearchParams(); + if (params?.template_type) queryParams.append('template_type', params.template_type); + if (params?.include_inactive) queryParams.append('include_inactive', 'true'); + + const queryString = queryParams.toString(); + const url = `organizers/${organizerId}/email-templates${queryString ? `?${queryString}` : ''}`; + + const response = await api.get>(url); + return response.data; + }, + + createForOrganizer: async (organizerId: IdParam, templateData: CreateEmailTemplateRequest) => { + const response = await api.post>(`organizers/${organizerId}/email-templates`, templateData); + return response.data; + }, + + updateForOrganizer: async (organizerId: IdParam, templateId: IdParam, templateData: UpdateEmailTemplateRequest) => { + const response = await api.put>(`organizers/${organizerId}/email-templates/${templateId}`, templateData); + return response.data; + }, + + deleteForOrganizer: async (organizerId: IdParam, templateId: IdParam) => { + const response = await api.delete<{ message: string }>(`organizers/${organizerId}/email-templates/${templateId}`); + return response.data; + }, + + previewForOrganizer: async (organizerId: IdParam, previewData: PreviewEmailTemplateRequest) => { + const response = await api.post(`organizers/${organizerId}/email-templates/preview`, previewData); + return response.data; + }, + + // Event-level templates + getByEvent: async (eventId: IdParam, params?: { template_type?: EmailTemplateType, include_inactive?: boolean }) => { + const queryParams = new URLSearchParams(); + if (params?.template_type) queryParams.append('template_type', params.template_type); + if (params?.include_inactive) queryParams.append('include_inactive', 'true'); + + const queryString = queryParams.toString(); + const url = `events/${eventId}/email-templates${queryString ? `?${queryString}` : ''}`; + + const response = await api.get>(url); + return response.data; + }, + + createForEvent: async (eventId: IdParam, templateData: CreateEmailTemplateRequest) => { + const response = await api.post>(`events/${eventId}/email-templates`, templateData); + return response.data; + }, + + updateForEvent: async (eventId: IdParam, templateId: IdParam, templateData: UpdateEmailTemplateRequest) => { + const response = await api.put>(`events/${eventId}/email-templates/${templateId}`, templateData); + return response.data; + }, + + deleteForEvent: async (eventId: IdParam, templateId: IdParam) => { + const response = await api.delete<{ message: string }>(`events/${eventId}/email-templates/${templateId}`); + return response.data; + }, + + previewForEvent: async (eventId: IdParam, previewData: PreviewEmailTemplateRequest) => { + const response = await api.post(`events/${eventId}/email-templates/preview`, previewData); + return response.data; + }, + + // Get available tokens + getAvailableTokens: async (templateType: EmailTemplateType) => { + const response = await api.get<{ tokens: EmailTemplateToken[] }>(`email-templates/tokens/${templateType}`); + return response.data; + }, + + // Get default templates + getDefaultTemplates: async () => { + const response = await api.get>('email-templates/defaults'); + return response.data; + }, +}; \ No newline at end of file diff --git a/frontend/src/api/order.client.ts b/frontend/src/api/order.client.ts index e474fad316..9edd5cc97d 100644 --- a/frontend/src/api/order.client.ts +++ b/frontend/src/api/order.client.ts @@ -79,8 +79,10 @@ export const orderClient = { return response.data; }, - cancel: async (eventId: IdParam, orderId: IdParam) => { - const response = await api.post>('events/' + eventId + '/orders/' + orderId + '/cancel'); + cancel: async (eventId: IdParam, orderId: IdParam, refund?: boolean) => { + const response = await api.post>('events/' + eventId + '/orders/' + orderId + '/cancel', { + refund: refund ?? false + }); return response.data; }, @@ -146,6 +148,8 @@ export const orderClientPublic = { const response = await publicApi.post<{ client_secret: string, account_id?: string, + public_key: string, + stripe_platform?: string, }>(`events/${eventId}/order/${orderShortId}/stripe/payment_intent`); return response.data; }, diff --git a/frontend/src/components/common/AttendeeCheckInTable/PermissionDeniedMessage.tsx b/frontend/src/components/common/AttendeeCheckInTable/PermissionDeniedMessage.tsx new file mode 100644 index 0000000000..8a005d19af --- /dev/null +++ b/frontend/src/components/common/AttendeeCheckInTable/PermissionDeniedMessage.tsx @@ -0,0 +1,32 @@ +import {Anchor, Button} from "@mantine/core"; +import {t, Trans} from "@lingui/macro"; +import classes from "./QrScanner.module.scss"; + +interface PermissionDeniedMessageProps { + onRequestPermission: () => void; + onClose: () => void; +} + +export const PermissionDeniedMessage = ({ + onRequestPermission, + onClose +}: PermissionDeniedMessageProps) => { + return ( +
+ + Camera permission was denied. Request + Permission again, + or if this doesn't work, + you will need to grant + this page access to your camera in your browser settings. + + +
+ +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx b/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx index 990dd7f5ad..121fde9e31 100644 --- a/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx +++ b/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx @@ -2,14 +2,15 @@ import {useEffect, useRef, useState} from 'react'; import QrScanner from 'qr-scanner'; import {useDebouncedValue} from '@mantine/hooks'; import classes from './QrScanner.module.scss'; -import {IconBulb, IconBulbOff, IconCameraRotate, IconVolume, IconVolumeOff, IconX} from "@tabler/icons-react"; -import {Anchor, Button, Menu} from "@mantine/core"; import {showError} from "../../../utilites/notifications.tsx"; -import {t, Trans} from "@lingui/macro"; +import {t} from "@lingui/macro"; +import {QrScannerControls} from './QrScannerControls'; +import {PermissionDeniedMessage} from './PermissionDeniedMessage'; interface QRScannerComponentProps { onAttendeeScanned: (attendeePublicId: string) => void; onClose: () => void; + isSoundOn?: boolean; } export const QRScannerComponent = (props: QRScannerComponentProps) => { @@ -34,13 +35,27 @@ export const QRScannerComponent = (props: QRScannerComponentProps) => { const scanInProgressAudioRef = useRef(null); const [isSoundOn, setIsSoundOn] = useState(() => { - const storedIsSoundOn = localStorage.getItem("qrScannerSoundOn"); + // Use the prop value if provided, otherwise fallback to unified storage + if (props.isSoundOn !== undefined) { + return props.isSoundOn; + } + const storedIsSoundOn = localStorage.getItem("scannerSoundOn"); return storedIsSoundOn === null ? true : JSON.parse(storedIsSoundOn); }); + // Sync with prop changes + useEffect(() => { + if (props.isSoundOn !== undefined) { + setIsSoundOn(props.isSoundOn); + } + }, [props.isSoundOn]); + useEffect(() => { - localStorage.setItem("qrScannerSoundOn", JSON.stringify(isSoundOn)); - }, [isSoundOn]); + // Only save to localStorage if not controlled by props + if (props.isSoundOn === undefined) { + localStorage.setItem("scannerSoundOn", JSON.stringify(isSoundOn)); + } + }, [isSoundOn, props.isSoundOn]); useEffect(() => { latestProcessedAttendeeIdsRef.current = processedAttendeeIds; @@ -163,7 +178,7 @@ export const QRScannerComponent = (props: QRScannerComponentProps) => { }; }, []); - const handleCameraSelection = (camera: QrScanner.Camera) => () => { + const handleCameraSelection = (camera: QrScanner.Camera) => { return qrScannerRef.current?.setCamera(camera.id) .then(() => updateFlashAvailability().catch(console.error)); }; @@ -171,55 +186,29 @@ export const QRScannerComponent = (props: QRScannerComponentProps) => { return (
{permissionDenied && ( -
- - Camera permission was denied. Request - Permission again, - or if this doesn't work, - you will need to grant - this page access to your camera in your browser settings. - - -
- -
-
+ )} - - + +
- {getAttendeeProductTitle(attendee)} + {getAttendeeProductTitle(attendee, attendee.product as Product)}
diff --git a/frontend/src/components/common/AttendeeTicket/AttendeeTicket.module.scss b/frontend/src/components/common/AttendeeTicket/AttendeeTicket.module.scss index 311a9b4381..6ea6adc1f2 100644 --- a/frontend/src/components/common/AttendeeTicket/AttendeeTicket.module.scss +++ b/frontend/src/components/common/AttendeeTicket/AttendeeTicket.module.scss @@ -1,140 +1,442 @@ @use "../../../styles/mixins"; -.attendee { - display: flex; - justify-content: space-between; - border-radius: 10px; - background-color: #ffffff; - border: 1px solid #ddd; +@media print { + @page { + size: A4; + margin: 0.5in; + } + + body { + font-size: 12pt; + line-height: 1.4; + } +} + +.ticket { + background: #ffffff; + border-radius: var(--mantine-radius-md); + border: 1px solid var(--mantine-color-gray-3); + max-width: 750px; + margin: 0 auto; overflow: hidden; - padding: 0; - margin-bottom: 20px; + display: flex; + flex-direction: column; @media print { + box-shadow: none; + border: none; + border-radius: 0; + max-width: 100%; + width: 100%; + height: auto; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + justify-content: flex-start; + page-break-after: avoid; page-break-inside: avoid; - break-inside: avoid; - margin-bottom: 100px; - - &:last-child { - margin-bottom: 0; - } } +} + +// Header +.header { + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + padding: 24px 32px; @include mixins.respond-below(sm) { - flex-direction: column-reverse; + padding: 20px; } - .attendeeInfo { - display: flex; + @media print { + background: #ffffff; + border-bottom: 2px solid #e0e0e0; + padding: 0 0 30px 0; + margin-bottom: 30px; + } +} + +.headerContent { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + + @include mixins.respond-below(sm) { flex-direction: column; + align-items: flex-start; + gap: 16px; + } +} + +.eventTitle { + font-size: 24px; + font-weight: 600; + color: #1e293b; + margin: 0; + line-height: 1.3; + letter-spacing: -0.01em; + + @include mixins.respond-below(sm) { + font-size: 20px; + } + + @media print { + font-size: 32px; + margin-bottom: 10px; + } +} + +.priceDisplay { + font-size: 18px; + font-weight: 600; + color: var(--accent); + white-space: nowrap; + + @include mixins.respond-below(sm) { + font-size: 16px; + } + + @media print { + font-size: 24px; + font-weight: 700; + } +} + +// Content +.content { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 32px; + padding: 32px; + flex: 1; + align-items: start; + + @include mixins.respond-below(md) { + grid-template-columns: 1fr; + gap: 24px; + padding: 24px 20px; + } + + @media print { + grid-template-columns: 2fr 1fr; + padding: 0; + gap: 50px; + flex: 1; + align-items: center; + } +} + +.contentLeft { + display: flex; + flex-direction: column; + gap: 28px; + + @include mixins.respond-below(sm) { + gap: 20px; + } + + @media print { + gap: 40px; justify-content: center; + } +} + +// Event Details +.eventDetails { + display: flex; + flex-direction: column; + gap: 20px; + + @include mixins.respond-below(sm) { + gap: 18px; + } + + @media print { + gap: 30px; + } +} + +.detailRow { + display: flex; + flex-direction: column; + gap: 6px; +} + +.detailLabel { + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #64748b; + + @media print { + font-size: 14px; + color: #666666; + font-weight: 600; + } +} + +.detailValue { + font-size: 16px; + font-weight: 500; + color: #1e293b; + line-height: 1.4; + + @include mixins.respond-below(sm) { + font-size: 15px; + } + + @media print { + font-size: 20px; + font-weight: 600; + } +} + +// Attendee Section +.attendeeSection { + background: #f1f5f9; + padding: 24px; + border-radius: 4px; + margin-top: auto; + + @include mixins.respond-below(sm) { padding: 20px; - flex: 1; - place-content: space-between; + } - .attendeeNameAndPrice { - place-self: flex-start; - display: flex; - justify-content: space-between; - flex-direction: row; - width: 100%; + @media print { + background: #ffffff; + padding: 35px; + border-radius: 8px; + border: 1px solid #d0d0d0; + } +} - .attendeeName { - flex: 1; - } - - .productName { - font-size: 0.9em; - font-weight: 900; - margin-bottom: 5px; - } - - .productPrice { - .badge { - background-color: #8bc34a; - color: #fff; - padding: 5px 10px; - border-radius: 10px; - font-size: 0.8em; - } - } - - h2 { - margin: 0; - } - } +.attendeeName { + font-size: 18px; + font-weight: 600; + color: #1e293b; + margin: 8px 0 4px; - .eventInfo { - .eventName { - font-weight: 900; - } - } + @include mixins.respond-below(sm) { + font-size: 17px; + } - a { - font-size: 0.9em; - } + @media print { + font-size: 24px; + margin: 12px 0 8px; + font-weight: 700; } +} - .qrCode { - .attendeeCode { - padding: 5px; - margin-bottom: 20px; - font-weight: 900; - font-size: 0.8em; - } +.attendeeEmail { + font-size: 14px; + color: #64748b; - justify-content: flex-end; - align-items: center; - display: flex; + @media print { + font-size: 18px; + color: #666666; + } +} + +// Footer Text +.footerText { + font-size: 14px; + color: #475569; + line-height: 1.5; + flex: 1; + + @include mixins.respond-below(sm) { + font-size: 13px; + } + + @media print { + color: #555555; + font-size: 16px; + line-height: 1.6; + } +} + +// Right Section - QR Code +.contentRight { + display: flex; + justify-content: center; + align-items: flex-start; + + @include mixins.respond-below(md) { + order: -1; + justify-content: center; + padding: 20px 0; + } +} + +.qrSection { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + justify-content: center; + + @media print { + gap: 30px; + justify-content: flex-start; + } +} + +.logoContainer { + display: flex; + justify-content: center; + margin-bottom: 16px; +} + +.logo { + max-width: 250px; + max-height: 200px; + object-fit: contain; + + //@include mixins.respond-below(sm) { + // max-width: 250px; + // max-height: 80px; + //} + // + //@media print { + // max-width: 250px; + // max-height: 80px; + //} +} + +.qrContainer { + position: relative; + padding: 16px; + background: #ffffff; + border: 3px solid var(--accent); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + + @include mixins.respond-below(sm) { + padding: 14px; + } + + @media print { + border-color: var(--accent); + padding: 25px; + border-width: 4px; + } +} + +.statusOverlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.95); + border-radius: 3px; +} + +.cancelled { + color: #dc2626; + font-weight: 600; + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.pending { + color: #ea580c; + font-weight: 600; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + text-align: center; +} + +.ticketId { + text-align: center; + display: flex; + flex-direction: column; + gap: 8px; +} + +.ticketIdValue { + font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace; + font-size: 14px; + font-weight: 600; + color: var(--accent); + background: #f8fafc; + padding: 8px 16px; + border-radius: 4px; + letter-spacing: 0.05em; + + @include mixins.respond-below(sm) { + font-size: 13px; + padding: 6px 12px; + } + + @media print { + color: var(--accent); + background: #f0f0f0; + font-size: 18px; + padding: 12px 20px; + font-weight: 700; + } +} + +// Footer +.footer { + background: #f8fafc; + border-top: 1px solid #e2e8f0; + padding: 20px 32px; + + @include mixins.respond-below(sm) { + padding: 16px 20px; + } + + @media print { + background: #ffffff; + border-top: 2px solid #e0e0e0; + padding: 30px 0 0 0; + margin-top: 30px; + } +} + +.footerContent { + display: flex; + justify-content: space-between; + align-items: center; + gap: 24px; + + @include mixins.respond-below(md) { flex-direction: column; - background-color: #f8f8f8; - border-left: 1px solid #ddd; - padding: 15px; + gap: 16px; + text-align: center; + } +} - @include mixins.respond-below(sm) { - border-left: none; - } - .qrImage { - svg { - width: 180px; - height: 180px; - } - - @media print { - svg { - width: 220px; - height: 220px; - } - } - - .cancelled { - height: 140px; - padding: 20px; - font-size: 1.1em; - display: flex; - justify-content: center; - align-items: center; - color: #d64646; - width: 140px; - } - - .awaitingPayment { - font-size: 1em; - display: flex; - justify-content: center; - align-items: center; - color: #e09300; - font-weight: 900; - margin-bottom: 10px; - } - } +.actions { + display: flex; + gap: 12px; + + @include mixins.respond-below(md) { + width: 100%; + justify-content: center; + } - .productButtons { - background: #ffffff; - border-radius: 5px; - margin-top: 20px; - border: 1px solid #d1d1d1; + @include mixins.respond-below(sm) { + flex-direction: column; + max-width: 280px; + gap: 8px; + + button { + width: 100%; } } + + @media print { + display: none; + } } diff --git a/frontend/src/components/common/AttendeeTicket/index.tsx b/frontend/src/components/common/AttendeeTicket/index.tsx index 622ac9b128..cb75a732b9 100644 --- a/frontend/src/components/common/AttendeeTicket/index.tsx +++ b/frontend/src/components/common/AttendeeTicket/index.tsx @@ -1,6 +1,5 @@ -import {Card} from "../Card"; import {getAttendeeProductPrice, getAttendeeProductTitle} from "../../../utilites/products.ts"; -import {Anchor, Button, CopyButton} from "@mantine/core"; +import {Button, CopyButton} from "@mantine/core"; import {formatCurrency} from "../../../utilites/currency.ts"; import {t} from "@lingui/macro"; import {prettyDate} from "../../../utilites/dates.ts"; @@ -8,6 +7,7 @@ import QRCode from "react-qr-code"; import {IconCopy, IconPrinter} from "@tabler/icons-react"; import {Attendee, Event, Product} from "../../../types.ts"; import classes from './AttendeeTicket.module.scss'; +import {imageUrl} from "../../../utilites/urlHelper.ts"; interface AttendeeTicketProps { event: Event; @@ -16,85 +16,154 @@ interface AttendeeTicketProps { hideButtons?: boolean; } -export const AttendeeTicket = ({attendee, product, event, hideButtons = false}: AttendeeTicketProps) => { +export const AttendeeTicket = ({ + attendee, + product, + event, + hideButtons = false, + }: AttendeeTicketProps) => { const productPrice = getAttendeeProductPrice(attendee, product); + const hasVenue = event?.settings?.location_details?.venue_name || event?.settings?.location_details?.address_line_1; + + const ticketDesignSettings = event?.settings?.ticket_design_settings; + const accentColor = ticketDesignSettings?.accent_color || '#6B46C1'; + const footerText = ticketDesignSettings?.footer_text; + const logoUrl = imageUrl('TICKET_LOGO', event?.images); + + const ticketStyle = { + '--accent': accentColor, + } as React.CSSProperties; + + const isCancelled = attendee.status === 'CANCELLED'; + const isAwaitingPayment = attendee.status === 'AWAITING_PAYMENT'; return ( - -
-
-
-

- {attendee.first_name} {attendee.last_name} -

-
- {getAttendeeProductTitle(attendee)} -
- - {attendee.email} - -
-
-
- {productPrice > 0 && formatCurrency(productPrice, event?.currency)} - {productPrice === 0 && t`Free`} -
-
-
-
-
- {event?.title} -
-
- {prettyDate(event.start_date, event.timezone)} +
+ {/* Header */} +
+
+

{event?.title}

+
+ {productPrice > 0 ? formatCurrency(productPrice, event?.currency) : t`Free`}
-
-
- {attendee.public_id} -
-
- {attendee.status === 'CANCELLED' && ( -
- {t`Cancelled`} + {/* Main Content */} +
+
+ {/* Event Details */} +
+
+
{t`Date & Time`}
+
+ {prettyDate(event.start_date, event.timezone)} +
- )} - {attendee.status === 'AWAITING_PAYMENT' && ( -
- {t`Awaiting Payment`} + {hasVenue && ( +
+
{t`Venue`}
+
+ {event?.settings?.location_details?.venue_name} + {event?.settings?.location_details?.address_line_1 && ( + <>, {event?.settings?.location_details?.address_line_1} + )} +
+
+ )} + +
+
{t`Ticket Type`}
+
+ {getAttendeeProductTitle(attendee, product)} +
- )} - {attendee.status !== 'CANCELLED' && } +
+ + {/* Attendee Information */} +
+
{t`Attendee`}
+
+ {attendee.first_name} {attendee.last_name} +
+
{attendee.email}
+
- {!hideButtons && ( -
- - - - {({copied, copy}) => ( - + {/* Right Section - Logo & QR Code */} +
+
+ {logoUrl && ( +
+ Event Logo +
+ )} + +
+ {(isCancelled || isAwaitingPayment) ? ( +
+ + {isCancelled ? t`Cancelled` : t`Awaiting Payment`} + +
+ ) : ( + )} - +
+ +
+
{t`Ticket ID`}
+
{attendee.public_id}
+
- )} +
- + + {/* Footer - Only show if there's footer text or buttons */} + {(footerText || !hideButtons) && ( +
+
+ {footerText && ( +
+ {footerText} +
+ )} + + {!hideButtons && ( +
+ + + + {({copied, copy}) => ( + + )} + +
+ )} +
+
+ )} +
); } diff --git a/frontend/src/components/common/CheckIn/AttendeeList.tsx b/frontend/src/components/common/CheckIn/AttendeeList.tsx new file mode 100644 index 0000000000..aac71f716b --- /dev/null +++ b/frontend/src/components/common/CheckIn/AttendeeList.tsx @@ -0,0 +1,111 @@ +import {Button, Loader} from "@mantine/core"; +import {IconTicket} from "@tabler/icons-react"; +import {t} from "@lingui/macro"; +import {Attendee} from "../../../types.ts"; +import classes from "../../layouts/CheckIn/CheckIn.module.scss"; + +interface AttendeeListProps { + attendees: Attendee[] | undefined; + products: { id: number; title: string; }[] | undefined; + isLoading: boolean; + isCheckInPending: boolean; + isDeletePending: boolean; + allowOrdersAwaitingOfflinePaymentToCheckIn: boolean; + onCheckInToggle: (attendee: Attendee) => void; + onClickSound?: () => void; +} + +export const AttendeeList = ({ + attendees, + products, + isLoading, + isCheckInPending, + isDeletePending, + allowOrdersAwaitingOfflinePaymentToCheckIn, + onCheckInToggle, + onClickSound + }: AttendeeListProps) => { + const checkInButtonText = (attendee: Attendee) => { + if (!allowOrdersAwaitingOfflinePaymentToCheckIn && attendee.status === 'AWAITING_PAYMENT') { + return t`Cannot Check In`; + } + + if (attendee.check_in) { + return t`Check Out`; + } + + return t`Check In`; + }; + + const getButtonColor = (attendee: Attendee) => { + if (attendee.check_in) { + return 'red'; + } + if (attendee.status === 'AWAITING_PAYMENT' && !allowOrdersAwaitingOfflinePaymentToCheckIn) { + return 'gray'; + } + return 'teal'; + }; + + if (isLoading || !attendees || !products) { + return ( +
+ +
+ ); + } + + if (attendees.length === 0) { + return ( +
+ No attendees to show. +
+ ); + } + + return ( +
+ {attendees.map(attendee => { + const isAttendeeAwaitingPayment = attendee.status === 'AWAITING_PAYMENT'; + + return ( +
+
+
+ {attendee.first_name} {attendee.last_name} +
+
+ {attendee.email} +
+ {isAttendeeAwaitingPayment && ( +
+ {t`Awaiting payment`} +
+ )} +
+ {attendee.public_id} +
+
+ {products.find(product => product.id === attendee.product_id)?.title} +
+
+
+ +
+
+ ); + })} +
+ ); +}; diff --git a/frontend/src/components/common/CheckIn/CheckInInfoModal.tsx b/frontend/src/components/common/CheckIn/CheckInInfoModal.tsx new file mode 100644 index 0000000000..a0d5584f2b --- /dev/null +++ b/frontend/src/components/common/CheckIn/CheckInInfoModal.tsx @@ -0,0 +1,63 @@ +import {Modal, Progress} from "@mantine/core"; +import {Trans} from "@lingui/macro"; +import Truncate from "../Truncate"; +import {CheckInList} from "../../../types.ts"; +import classes from "../../layouts/CheckIn/CheckIn.module.scss"; + +interface CheckInInfoModalProps { + isOpen: boolean; + checkInList: CheckInList | undefined; + onClose: () => void; +} + +export const CheckInInfoModal = ({ + isOpen, + checkInList, + onClose +}: CheckInInfoModalProps) => { + if (!checkInList) return null; + + return ( + + + + + + + + + +
+
+ <> +

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

+ + + +
+ + {checkInList.description && ( +
+ {checkInList.description} +
+ )} +
+
+
+ ); +}; diff --git a/frontend/src/components/common/CheckIn/CheckInOptionsModal.tsx b/frontend/src/components/common/CheckIn/CheckInOptionsModal.tsx new file mode 100644 index 0000000000..d89fba3114 --- /dev/null +++ b/frontend/src/components/common/CheckIn/CheckInOptionsModal.tsx @@ -0,0 +1,64 @@ +import {Alert, Button, Modal, Stack} from "@mantine/core"; +import {IconAlertCircle, IconCreditCard, IconUserCheck} from "@tabler/icons-react"; +import {t, Trans} from "@lingui/macro"; +import {Attendee} from "../../../types.ts"; + +interface CheckInOptionsModalProps { + isOpen: boolean; + attendee: Attendee | null; + isPending: boolean; + onClose: () => void; + onCheckIn: (action: 'check-in' | 'check-in-and-mark-order-as-paid') => void; +} + +export const CheckInOptionsModal = ({ + isOpen, + attendee, + isPending, + onClose, + onCheckIn +}: CheckInOptionsModalProps) => { + if (!attendee) return null; + + return ( + Check in {attendee.first_name} {attendee.last_name}} + size="md" + > + + } + variant={'light'} + title={t`Unpaid Order`}> + {t`This attendee has an unpaid order.`} + + + + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/common/CheckIn/HidScannerStatus.tsx b/frontend/src/components/common/CheckIn/HidScannerStatus.tsx new file mode 100644 index 0000000000..4cc250f7be --- /dev/null +++ b/frontend/src/components/common/CheckIn/HidScannerStatus.tsx @@ -0,0 +1,55 @@ +import {Button} from "@mantine/core"; +import {IconScan, IconX} from "@tabler/icons-react"; +import {t} from "@lingui/macro"; +import {showSuccess} from "../../../utilites/notifications.tsx"; + +interface HidScannerStatusProps { + isActive: boolean; + pageHasFocus: boolean; + onDisable: () => void; +} + +export const HidScannerStatus = ({ + isActive, + pageHasFocus, + onDisable +}: HidScannerStatusProps) => { + if (!isActive) return null; + + return ( +
+
+ + + {pageHasFocus + ? 'USB Scanner Active - Ready to Scan' + : 'USB Scanner Paused - Click anywhere to resume scanning'} + +
+ +
+ ); +}; diff --git a/frontend/src/components/common/CheckIn/ScannerSelectionModal.tsx b/frontend/src/components/common/CheckIn/ScannerSelectionModal.tsx new file mode 100644 index 0000000000..3ffe4c65eb --- /dev/null +++ b/frontend/src/components/common/CheckIn/ScannerSelectionModal.tsx @@ -0,0 +1,62 @@ +import {Button, Modal, Stack} from "@mantine/core"; +import {IconCamera, IconScan} from "@tabler/icons-react"; +import {t} from "@lingui/macro"; +import {showSuccess} from "../../../utilites/notifications.tsx"; + +interface ScannerSelectionModalProps { + isOpen: boolean; + isHidScannerActive: boolean; + onClose: () => void; + onCameraSelect: () => void; + onHidScannerSelect: () => void; +} + +export const ScannerSelectionModal = ({ + isOpen, + isHidScannerActive, + onClose, + onCameraSelect, + onHidScannerSelect +}: ScannerSelectionModalProps) => { + return ( + + + + + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/common/Editor/Controls/InsertLiquidVariableControl.tsx b/frontend/src/components/common/Editor/Controls/InsertLiquidVariableControl.tsx new file mode 100644 index 0000000000..411e23dd42 --- /dev/null +++ b/frontend/src/components/common/Editor/Controls/InsertLiquidVariableControl.tsx @@ -0,0 +1,160 @@ +import {RichTextEditor, useRichTextEditorContext} from '@mantine/tiptap'; +import {IconBraces} from '@tabler/icons-react'; +import {Menu, ScrollArea, Stack, Text} from '@mantine/core'; +import {EmailTemplateType} from '../../../../types'; +import {t} from '@lingui/macro'; + +interface InsertLiquidVariableControlProps { + templateType?: EmailTemplateType; +} + +interface TemplateVariable { + label: string; + value: string; + description?: string; + category?: string; +} + +const TEMPLATE_VARIABLES: Record = { + order_confirmation: [ + // Order Information + {label: t`Order Number`, value: 'order.number', description: t`Unique order reference`, category: t`Order`}, + {label: t`Order Total`, value: 'order.total', description: t`Total amount paid`, category: t`Order`}, + {label: t`Order Date`, value: 'order.date', description: t`Date order was placed`, category: t`Order`}, + {label: t`Order First Name`, value: 'order.first_name', description: t`Customer's first name`, category: t`Order`}, + {label: t`Order Last Name`, value: 'order.last_name', description: t`Customer's last name`, category: t`Order`}, + {label: t`Order Email`, value: 'order.email', description: t`Customer's email address`, category: t`Order`}, + {label: t`Order URL`, value: 'order.url', description: t`Link to order details`, category: t`Order`}, + {label: t`Order Is Awaiting Offline Payment`, value: 'order.is_awaiting_offline_payment', description: t`True if payment pending`, category: t`Order`}, + {label: t`Order Locale`, value: 'order.locale', description: t`The locale of the customer`, category: t`Order`}, + {label: t`Order Currency`, value: 'order.currency', description: t`The currency of the order`, category: t`Order`}, + {label: t`Offline Payment`, value: 'order.is_offline_payment', description: t`True if offline payment`, category: t`Order`}, + + // Event Information + {label: t`Event Title`, value: 'event.title', description: t`Name of the event`, category: t`Event`}, + {label: t`Event Date`, value: 'event.date', description: t`Date of the event`, category: t`Event`}, + {label: t`Event Time`, value: 'event.time', description: t`Start time of the event`, category: t`Event`}, + {label: t`Event Full Address`, value: 'event.full_address', description: t`The full event address`, category: t`Event`}, + {label: t`Event Description`, value: 'event.description', description: t`Event details`, category: t`Event`}, + {label: t`Event Timezone`, value: 'event.timezone', description: t`Event timezone`, category: t`Event`}, + {label: t`Event Venue`, value: 'event.location_details.venue_name', description: t`The event venue`, category: t`Event`}, + + + // Organization + {label: t`Organizer Name`, value: 'organizer.name', description: t`Event organizer name`, category: t`Organization`}, + {label: t`Organizer Email`, value: 'organizer.email', description: t`Organizer email address`, category: t`Organization`}, + + // Settings + {label: t`Support Email`, value: 'settings.support_email', description: t`Contact email for support`, category: t`Settings`}, + {label: t`Offline Payment Instructions`, value: 'settings.offline_payment_instructions', description: t`How to pay offline`, category: t`Settings`}, + {label: t`Post Checkout Message`, value: 'settings.post_checkout_message', description: t`Custom message after checkout`, category: t`Settings`}, + ], + attendee_ticket: [ + // Attendee Information + {label: t`Attendee Name`, value: 'attendee.name', description: t`Ticket holder's name`, category: t`Attendee`}, + {label: t`Attendee Email`, value: 'attendee.email', description: t`Ticket holder's email`, category: t`Attendee`}, + + // Ticket Information + {label: t`Ticket Name`, value: 'ticket.name', description: t`Type of ticket`, category: t`Ticket`}, + {label: t`Ticket Price`, value: 'ticket.price', description: t`Price of the ticket`, category: t`Ticket`}, + {label: t`Ticket URL`, value: 'ticket.url', description: t`Link to ticket`, category: t`Ticket`}, + + // Order Information + {label: t`Order Payment Pending`, value: 'order.is_awaiting_offline_payment', description: t`True if payment pending`, category: t`Order`}, + {label: t`Order Status`, value: 'order.status', description: t`Order Status`, category: t`Order`}, + {label: t`Offline Payment`, value: 'is_offline_payment', description: t`True if offline payment`, category: t`Order`}, + + // Event Information + {label: t`Event Title`, value: 'event.title', description: t`Name of the event`, category: t`Event`}, + {label: t`Event Date`, value: 'event.date', description: t`Date of the event`, category: t`Event`}, + {label: t`Event Time`, value: 'event.time', description: t`Start time of the event`, category: t`Event`}, + {label: t`Event Location`, value: 'event.full_address', description: t`The full event address`, category: t`Event`}, + {label: t`Event Description`, value: 'event.description', description: t`Event details`, category: t`Event`}, + {label: t`Event Timezone`, value: 'event.timezone', description: t`Event timezone`, category: t`Event`}, + {label: t`Event Venue`, value: 'event.location_details.venue_name', description: t`The event venue`, category: t`Event`}, + + // Organization + {label: t`Organizer Name`, value: 'organizer.name', description: t`Event organizer name`, category: t`Organization`}, + {label: t`Organizer Email`, value: 'organizer.email', description: t`Organizer email address`, category: t`Organization`}, + + // Settings + {label: t`Support Email`, value: 'settings.support_email', description: t`Contact email for support`, category: t`Settings`}, + {label: t`Offline Payment Instructions`, value: 'settings.offline_payment_instructions', description: t`How to pay offline`, category: t`Settings`}, + {label: t`Post Checkout Message`, value: 'settings.post_checkout_message', description: t`Custom message after checkout`, category: t`Settings`}, + ], +}; + +export function InsertLiquidVariableControl({templateType = 'order_confirmation'}: InsertLiquidVariableControlProps) { + const {editor} = useRichTextEditorContext(); + const variables = TEMPLATE_VARIABLES[templateType] || []; + + const handleInsertVariable = (variable: string) => { + // Insert as plain text with Liquid syntax + editor?.chain().focus().insertContent(`{{ ${variable} }}`).run(); + }; + + // Group variables by category + const groupedVariables = variables.reduce((acc, variable) => { + const category = variable.category || t`Other`; + if (!acc[category]) { + acc[category] = []; + } + acc[category].push(variable); + return acc; + }, {} as Record); + + return ( + + + + + + + + + + {Object.entries(groupedVariables).map(([category, categoryVariables]) => ( +
+ {category} + {categoryVariables.map((variable) => ( + handleInsertVariable(variable.value)} + p="sm" + > + + + {variable.label} + + + {`{{ ${variable.value} }}`} + + {variable.description && ( + + {variable.description} + + )} + + + ))} +
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/common/Editor/Controls/LiquidTokenControl.tsx b/frontend/src/components/common/Editor/Controls/LiquidTokenControl.tsx new file mode 100644 index 0000000000..86d6cf369f --- /dev/null +++ b/frontend/src/components/common/Editor/Controls/LiquidTokenControl.tsx @@ -0,0 +1,81 @@ +import {Badge, Menu, ScrollArea, Text} from '@mantine/core'; +import {IconBraces} from '@tabler/icons-react'; +import {RichTextEditor, useRichTextEditorContext} from '@mantine/tiptap'; +import {EmailTemplateToken, EmailTemplateType} from '../../../../types'; +import {useGetEmailTemplateTokens} from '../../../../queries/useGetEmailTemplateTokens'; +import {t, Trans} from '@lingui/macro'; + +interface LiquidTokenControlProps { + templateType: EmailTemplateType; +} + +export const LiquidTokenControl = ({templateType}: LiquidTokenControlProps) => { + const {editor} = useRichTextEditorContext(); + const {data: tokensData, isLoading} = useGetEmailTemplateTokens(templateType); + + const insertToken = (token: EmailTemplateToken) => { + if (editor) { + editor.chain().focus().insertContent(token.token).run(); + } + }; + + if (isLoading || !tokensData?.tokens) { + return ( + + + + ); + } + + const tokens = tokensData.tokens.filter(token => !token.token.startsWith('{% if')); + + return ( + + + + + + + + + + Available Tokens + + + + {tokens.map((token, index) => ( + insertToken(token)} + p="sm" + > +
+ + {token.token} + + + {token.description} + + + {token.example} + +
+
+ ))} +
+
+
+ ); +}; diff --git a/frontend/src/components/common/Editor/Extensions/LiquidTokenExtension/TokenComponent.tsx b/frontend/src/components/common/Editor/Extensions/LiquidTokenExtension/TokenComponent.tsx new file mode 100644 index 0000000000..43affb9c13 --- /dev/null +++ b/frontend/src/components/common/Editor/Extensions/LiquidTokenExtension/TokenComponent.tsx @@ -0,0 +1,57 @@ +import {NodeViewWrapper} from '@tiptap/react'; +import {Badge, Tooltip} from '@mantine/core'; +import {IconCode} from '@tabler/icons-react'; + +interface TokenComponentProps { + node: { + attrs: { + tokenName: string; + tokenDescription: string; + }; + }; + selected: boolean; +} + +export const TokenComponent = ({node, selected}: TokenComponentProps) => { + const {tokenName, tokenDescription} = node.attrs; + + const tokenBadge = ( + } + style={{ + cursor: 'default', + fontFamily: 'monospace', + fontSize: '12px', + whiteSpace: 'nowrap', + display: 'inline-flex', + alignItems: 'center', + verticalAlign: 'baseline', + }} + > + {tokenName} + + ); + + return ( + + {tokenDescription ? ( + + {tokenBadge} + + ) : ( + tokenBadge + )} + + ); +}; diff --git a/frontend/src/components/common/Editor/Extensions/LiquidTokenExtension/index.tsx b/frontend/src/components/common/Editor/Extensions/LiquidTokenExtension/index.tsx new file mode 100644 index 0000000000..e4b8a15dd9 --- /dev/null +++ b/frontend/src/components/common/Editor/Extensions/LiquidTokenExtension/index.tsx @@ -0,0 +1,69 @@ +import {mergeAttributes, Node} from '@tiptap/core'; +import {ReactNodeViewRenderer} from '@tiptap/react'; +import {TokenComponent} from './TokenComponent'; + +declare module '@tiptap/core' { + interface Commands { + liquidToken: { + insertLiquidToken: (tokenName: string, tokenDescription?: string) => ReturnType; + }; + } +} + +export const LiquidToken = Node.create({ + name: 'liquidToken', + group: 'inline', + inline: true, + atom: true, + + addAttributes() { + return { + tokenName: { + default: '', + parseHTML: element => element.getAttribute('data-token-name'), + renderHTML: attributes => ({ + 'data-token-name': attributes.tokenName, + }), + }, + tokenDescription: { + default: '', + parseHTML: element => element.getAttribute('data-token-description'), + renderHTML: attributes => ({ + 'data-token-description': attributes.tokenDescription, + }), + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'span[data-type="liquid-token"]', + }, + ]; + }, + + renderHTML({HTMLAttributes}) { + return ['span', mergeAttributes({'data-type': 'liquid-token'}, HTMLAttributes)]; + }, + + addNodeView() { + return ReactNodeViewRenderer(TokenComponent); + }, + + addCommands() { + return { + insertLiquidToken: (tokenName: string, tokenDescription?: string) => ({commands}) => { + return commands.insertContent({ + type: this.name, + attrs: { + tokenName, + tokenDescription: tokenDescription || '', + }, + }); + }, + }; + }, +}); + +export default LiquidToken; diff --git a/frontend/src/components/common/Editor/Extensions/LiquidVariableExtension.tsx b/frontend/src/components/common/Editor/Extensions/LiquidVariableExtension.tsx new file mode 100644 index 0000000000..c60b682421 --- /dev/null +++ b/frontend/src/components/common/Editor/Extensions/LiquidVariableExtension.tsx @@ -0,0 +1,51 @@ +import { Node } from '@tiptap/core'; + +export const LiquidVariable = Node.create({ + name: 'liquidVariable', + + group: 'inline', + + inline: true, + + atom: true, + + addAttributes() { + return { + variable: { + default: null, + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'span[data-liquid-variable]', + }, + ]; + }, + + renderHTML({ node }) { + return [ + 'span', + { + 'data-liquid-variable': node.attrs.variable, + style: 'background-color: #e8f4ff; color: #0066cc; padding: 2px 6px; border-radius: 4px; font-family: monospace; font-size: 0.9em; white-space: nowrap;', + }, + `{{ ${node.attrs.variable} }}`, + ]; + }, + + addCommands() { + return { + insertLiquidVariable: (variable: string) => ({ chain }) => { + return chain() + .insertContent({ + type: this.name, + attrs: { variable }, + }) + .run(); + }, + }; + }, +}); \ No newline at end of file diff --git a/frontend/src/components/common/Editor/index.tsx b/frontend/src/components/common/Editor/index.tsx index c899f388b1..e19ee0325f 100644 --- a/frontend/src/components/common/Editor/index.tsx +++ b/frontend/src/components/common/Editor/index.tsx @@ -13,6 +13,7 @@ import classNames from "classnames"; import {Trans} from "@lingui/macro"; import {InsertImageControl} from "./Controls/InsertImageControl"; import {ImageResize} from "./Extensions/ImageResizeExtension"; +import {Extension} from '@tiptap/core'; interface EditorProps { onChange: (value: string) => void; @@ -21,10 +22,12 @@ interface EditorProps { description?: React.ReactNode; required?: boolean; className?: string; - error?: string; + error?: string | React.ReactNode; editorType?: 'full' | 'simple'; maxLength?: number; size?: MantineFontSize; + additionalExtensions?: Extension[]; + additionalToolbarControls?: React.ReactNode; } export const Editor = ({ @@ -38,19 +41,33 @@ export const Editor = ({ editorType = 'full', maxLength, size = 'md', + additionalExtensions = [], + additionalToolbarControls, }: EditorProps) => { const [charError, setCharError] = useState(null); const editor = useEditor({ extensions: [ - StarterKit, + StarterKit.configure({ + paragraph: { + HTMLAttributes: { + style: 'margin: 0.5em 0;' + } + }, + hardBreak: { + HTMLAttributes: { + 'data-type': 'hard-break' + } + } + }), Underline, Link, TextAlign.configure({types: ['heading', 'paragraph']}), Image, ImageResize, TextStyle, - Color + Color, + ...additionalExtensions ], onUpdate: ({editor}) => { const html = editor.getHTML(); @@ -195,6 +212,8 @@ export const Editor = ({ )} + + {additionalToolbarControls} diff --git a/frontend/src/components/common/EmailTemplateEditor/CTAConfiguration.tsx b/frontend/src/components/common/EmailTemplateEditor/CTAConfiguration.tsx new file mode 100644 index 0000000000..b5b09ac59d --- /dev/null +++ b/frontend/src/components/common/EmailTemplateEditor/CTAConfiguration.tsx @@ -0,0 +1,37 @@ +import { Stack, TextInput, Paper, Text } from '@mantine/core'; +import { Trans, t } from '@lingui/macro'; +import React from "react"; + +interface CTAConfigurationProps { + label: string; + onLabelChange: (label: string) => void; + error?: string | React.ReactNode; +} + +export const CTAConfiguration = ({ + label, + onLabelChange, + error +}: CTAConfigurationProps) => { + return ( + + + + Call-to-Action Button + + + Every email template must include a call-to-action button that links to the appropriate page + + + Button Label} + placeholder={t`View Order`} + value={label} + onChange={(event) => onLabelChange(event.currentTarget.value)} + error={error} + required + /> + + + ); +}; diff --git a/frontend/src/components/common/EmailTemplateEditor/EmailTemplateEditor.module.scss b/frontend/src/components/common/EmailTemplateEditor/EmailTemplateEditor.module.scss new file mode 100644 index 0000000000..beee7dfb89 --- /dev/null +++ b/frontend/src/components/common/EmailTemplateEditor/EmailTemplateEditor.module.scss @@ -0,0 +1,117 @@ +.editor { + .templatePreview { + border: 1px solid var(--mantine-color-gray-3); + border-radius: var(--mantine-radius-md); + background: var(--mantine-color-gray-0); + + .previewHeader { + padding: 1rem; + background: var(--mantine-color-gray-1); + border-bottom: 1px solid var(--mantine-color-gray-3); + border-radius: var(--mantine-radius-md) var(--mantine-radius-md) 0 0; + } + + .previewContent { + padding: 1.5rem; + + :global(.mantine-TypographyStylesProvider-root) { + line-height: 1.6; + + h1, h2, h3, h4, h5, h6 { + margin-top: 1.5rem; + margin-bottom: 0.5rem; + + &:first-child { + margin-top: 0; + } + } + + p { + margin-bottom: 1rem; + + &:last-child { + margin-bottom: 0; + } + } + + ul, ol { + margin-bottom: 1rem; + padding-left: 1.5rem; + } + + li { + margin-bottom: 0.25rem; + } + + strong { + font-weight: 600; + } + + code { + background: var(--mantine-color-gray-1); + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + font-size: 0.875em; + } + } + } + } + + .tokenReference { + background: var(--mantine-color-blue-0); + border: 1px solid var(--mantine-color-blue-2); + border-radius: var(--mantine-radius-sm); + + .tokenItem { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--mantine-color-blue-1); + + &:last-child { + border-bottom: none; + } + + .tokenName { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.8rem; + background: var(--mantine-color-blue-1); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + display: inline-block; + margin-bottom: 0.25rem; + } + + .tokenDescription { + font-size: 0.8rem; + color: var(--mantine-color-gray-7); + } + } + } +} + +// Dark theme adjustments +[data-mantine-color-scheme='dark'] { + .editor { + .templatePreview { + border-color: var(--mantine-color-dark-4); + background: var(--mantine-color-dark-7); + + .previewHeader { + background: var(--mantine-color-dark-6); + border-bottom-color: var(--mantine-color-dark-4); + } + } + + .tokenReference { + background: var(--mantine-color-dark-6); + border-color: var(--mantine-color-blue-8); + + .tokenItem { + border-bottom-color: var(--mantine-color-dark-5); + + .tokenName { + background: var(--mantine-color-dark-5); + } + } + } + } +} \ No newline at end of file diff --git a/frontend/src/components/common/EmailTemplateEditor/EmailTemplateEditor.tsx b/frontend/src/components/common/EmailTemplateEditor/EmailTemplateEditor.tsx new file mode 100644 index 0000000000..8cd25b00cc --- /dev/null +++ b/frontend/src/components/common/EmailTemplateEditor/EmailTemplateEditor.tsx @@ -0,0 +1,206 @@ +import {useEffect, useState} from 'react'; +import {ActionIcon, Button, Divider, Group, Stack, Switch, Tabs, Text, TextInput,} from '@mantine/core'; +import {useForm} from '@mantine/form'; +import {IconBraces, IconEye, IconX} from '@tabler/icons-react'; +import {t, Trans} from '@lingui/macro'; +import {CreateEmailTemplateRequest, EmailTemplate, EmailTemplateType, UpdateEmailTemplateRequest, DefaultEmailTemplate} from '../../../types'; +import {EmailTemplatePreviewPane} from './EmailTemplatePreviewPane'; +import {CTAConfiguration} from './CTAConfiguration'; +import classes from './EmailTemplateEditor.module.scss'; +import {Editor} from "../Editor"; +import {LiquidTokenControl} from '../Editor/Controls/LiquidTokenControl'; + +interface EmailTemplateEditorProps { + templateType: EmailTemplateType; + template?: EmailTemplate; + defaultTemplate?: DefaultEmailTemplate; + onSave: (data: CreateEmailTemplateRequest | UpdateEmailTemplateRequest, form?: any) => void; + onPreview: (data: { subject: string; body: string; template_type: EmailTemplateType; ctaLabel: string }) => void; + onClose?: () => void; + isSaving?: boolean; + isPreviewLoading?: boolean; + previewData?: { subject: string; body: string } | null; +} + +export const EmailTemplateEditor = ({ + templateType, + template, + defaultTemplate, + onSave, + onPreview, + onClose, + isSaving = false, + isPreviewLoading = false, + previewData + }: EmailTemplateEditorProps) => { + const [activeTab, setActiveTab] = useState('editor'); + + const form = useForm({ + initialValues: { + subject: template?.subject || defaultTemplate?.subject || '', + body: template?.body || defaultTemplate?.body || '', + ctaLabel: template?.cta?.label || '', + isActive: template?.is_active ?? true, + }, + validate: { + subject: (value) => (!value ? t`Subject is required` : null), + body: (value) => (!value ? t`Body is required` : null), + ctaLabel: (value) => (!value ? t`CTA label is required` : null), + }, + }); + + // Update form values when defaultTemplate is loaded (for new templates ONLY) + useEffect(() => { + if (!template && defaultTemplate && defaultTemplate.subject && defaultTemplate.body) { + form.setFieldValue('subject', defaultTemplate.subject); + form.setFieldValue('body', defaultTemplate.body); + form.setFieldValue('ctaLabel', templateType === 'order_confirmation' ? t`View Order` : t`View Ticket`); + form.setFieldValue('isActive', true); + } + }, [defaultTemplate, template]); + + // Manual preview trigger only when preview tab is active + const triggerPreview = () => { + if (form.values.subject && form.values.body) { + onPreview({ + subject: form.values.subject, + body: form.values.body, + template_type: templateType, + ctaLabel: form.values.ctaLabel || (templateType === 'order_confirmation' ? t`View Order` : t`View Ticket`), + }); + } + }; + + // Trigger preview when switching to preview tab + useEffect(() => { + if (activeTab === 'preview' && form.values.subject && form.values.body) { + triggerPreview(); + } + }, [activeTab]); + + const handleSave = (values: typeof form.values) => { + const templateData = { + ...(template ? {} : {template_type: templateType}), + subject: values.subject, + body: values.body, + ctaLabel: values.ctaLabel, + isActive: values.isActive, + }; + + onSave(templateData as CreateEmailTemplateRequest | UpdateEmailTemplateRequest, form); + }; + + const templateTypeLabels: Record = { + 'order_confirmation': t`Order Confirmation`, + 'attendee_ticket': t`Attendee Ticket`, + }; + + return ( +
+
+ + +
+ + {template ? ( + Edit {templateTypeLabels[templateType]} Template + ) : ( + Create {templateTypeLabels[templateType]} Template + )} + + + Customize your email template using Liquid templating + +
+ {onClose && ( + + + + )} +
+ + Subject} + placeholder={t`Enter email subject...`} + required + {...form.getInputProps('subject')} + /> + + setActiveTab(value || 'editor')}> + + }> + Editor + + }> + Preview + + + + + + form.setFieldValue('body', value)} + error={form.errors.body} + label={Email Body} + description={Use Liquid templating to personalize your emails} + editorType="full" + additionalToolbarControls={ + + } + /> + + form.setFieldValue('ctaLabel', label)} + error={form.errors.ctaLabel} + /> + + + + + + + + + + + + + + + + Template Active} + description={Enable this template for sending emails} + {...form.getInputProps('isActive', {type: 'checkbox'})} + /> + + +
+
+
+ ); +}; diff --git a/frontend/src/components/common/EmailTemplateEditor/EmailTemplatePreviewPane.tsx b/frontend/src/components/common/EmailTemplateEditor/EmailTemplatePreviewPane.tsx new file mode 100644 index 0000000000..c2f50380ad --- /dev/null +++ b/frontend/src/components/common/EmailTemplateEditor/EmailTemplatePreviewPane.tsx @@ -0,0 +1,76 @@ +import {Alert, Divider, LoadingOverlay, Stack, Text, TypographyStylesProvider} from '@mantine/core'; +import {IconAlertCircle, IconEye} from '@tabler/icons-react'; +import {Trans} from '@lingui/macro'; +import classes from './EmailTemplateEditor.module.scss'; + +interface EmailTemplatePreviewPaneProps { + subject: string; + previewData?: { subject: string; body: string } | null; + isLoading?: boolean; + error?: string | null; +} + +export const EmailTemplatePreviewPane = ({ + subject, + previewData, + isLoading = false, + error + }: EmailTemplatePreviewPaneProps) => { + const hasContent = previewData?.subject && previewData?.body; + + return ( + +
+
+ + + Email Preview + + + {previewData?.subject || subject || Subject will appear here} + + +
+ +
+ + + {error && ( + }> + {error} + + )} + + {!error && hasContent && ( + +
+ + )} + + {!error && !hasContent && !isLoading && ( + + + + Enter a subject and body to see the preview + + + )} +
+
+ + {previewData && ( + <> + + + This preview shows how your email will look with sample data. Actual emails will use real + values. + + + )} + + ); +}; diff --git a/frontend/src/components/common/EmailTemplateEditor/index.tsx b/frontend/src/components/common/EmailTemplateEditor/index.tsx new file mode 100644 index 0000000000..c59b91dc8d --- /dev/null +++ b/frontend/src/components/common/EmailTemplateEditor/index.tsx @@ -0,0 +1,3 @@ +export { EmailTemplateEditor } from './EmailTemplateEditor'; +export { EmailTemplatePreviewPane } from './EmailTemplatePreviewPane'; +export { CTAConfiguration } from './CTAConfiguration'; diff --git a/frontend/src/components/common/EmailTemplateSettings/EmailTemplateSettingsBase.tsx b/frontend/src/components/common/EmailTemplateSettings/EmailTemplateSettingsBase.tsx new file mode 100644 index 0000000000..8d472433af --- /dev/null +++ b/frontend/src/components/common/EmailTemplateSettings/EmailTemplateSettingsBase.tsx @@ -0,0 +1,378 @@ +import {useState} from 'react'; +import {ActionIcon, Alert, Badge, Button, Group, LoadingOverlay, Modal, Paper, Stack, Text} from '@mantine/core'; +import {IconEdit, IconInfoCircle, IconMail, IconPlus, IconTrash} from '@tabler/icons-react'; +import {t, Trans} from '@lingui/macro'; +import {useDisclosure} from '@mantine/hooks'; +import {EmailTemplateEditor} from '../EmailTemplateEditor'; +import {confirmationDialog} from '../../../utilites/confirmationDialog'; +import {showSuccess, showError} from '../../../utilites/notifications'; +import {useFormErrorResponseHandler} from '../../../hooks/useFormErrorResponseHandler'; +import { + CreateEmailTemplateRequest, + EmailTemplate, + EmailTemplateType, + UpdateEmailTemplateRequest, + DefaultEmailTemplate +} from '../../../types'; +import {Card} from '../Card'; +import {HeadingWithDescription} from '../Card/CardHeading'; + +interface EmailTemplateSettingsBaseProps { + // Context + contextId: string | number; + contextType: 'event' | 'organizer'; + + // Data + templates: EmailTemplate[]; + defaultTemplates?: Record; + isLoading: boolean; + + // Mutations + createMutation: { + mutate: (params: any, options?: any) => void; + isPending: boolean; + }; + updateMutation: { + mutate: (params: any, options?: any) => void; + isPending: boolean; + }; + deleteMutation: { + mutate: (params: any, options?: any) => void; + isPending: boolean; + }; + previewMutation: { + mutate: (params: any) => void; + isPending: boolean; + data?: any; + }; + + // Callbacks + onCreateTemplate?: (type: EmailTemplateType) => void; + onSaveSuccess?: () => void; + onDeleteSuccess?: () => void; + onError?: (error: any, message: string) => void; +} + +export const EmailTemplateSettingsBase = ({ + contextId, + contextType, + templates, + defaultTemplates, + isLoading, + createMutation, + updateMutation, + deleteMutation, + previewMutation, + onCreateTemplate, + onSaveSuccess, + onDeleteSuccess, + onError +}: EmailTemplateSettingsBaseProps) => { + const [editorOpened, {open: openEditor, close: closeEditor}] = useDisclosure(false); + const [editingTemplate, setEditingTemplate] = useState(null); + const [editingType, setEditingType] = useState('order_confirmation'); + const handleFormError = useFormErrorResponseHandler(); + + const orderConfirmationTemplate = templates.find(t => t.template_type === 'order_confirmation'); + const attendeeTicketTemplate = templates.find(t => t.template_type === 'attendee_ticket'); + + const handleCreateTemplate = (type: EmailTemplateType) => { + setEditingTemplate(null); + setEditingType(type); + onCreateTemplate?.(type); + openEditor(); + }; + + const handleEditTemplate = (template: EmailTemplate) => { + setEditingTemplate(template); + setEditingType(template.template_type); + openEditor(); + }; + + const handleDeleteTemplate = (template: EmailTemplate) => { + const fallbackMessage = contextType === 'event' + ? t`Are you sure you want to delete this template? This action cannot be undone and emails will fall back to the organizer or default template.` + : t`Are you sure you want to delete this template? This action cannot be undone and emails will fall back to the default template.`; + + confirmationDialog( + fallbackMessage, + () => { + const params = contextType === 'event' + ? {eventId: contextId, templateId: template.id} + : {organizerId: contextId, templateId: template.id}; + + deleteMutation.mutate(params, { + onSuccess: () => { + showSuccess(t`Template deleted successfully`); + onDeleteSuccess?.(); + }, + onError: (error: any) => { + showError(t`Failed to delete template`); + onError?.(error, t`Failed to delete template`); + }, + }); + }, + { confirm: t`Delete Template`, cancel: t`Cancel` } + ); + }; + + const handleSaveTemplate = (data: CreateEmailTemplateRequest | UpdateEmailTemplateRequest, editorForm?: any) => { + if (editingTemplate) { + const params = contextType === 'event' + ? {eventId: contextId, templateId: editingTemplate.id, templateData: data as UpdateEmailTemplateRequest} + : {organizerId: contextId, templateId: editingTemplate.id, templateData: data as UpdateEmailTemplateRequest}; + + updateMutation.mutate(params, { + onSuccess: () => { + showSuccess(t`Template saved successfully`); + closeEditor(); + onSaveSuccess?.(); + }, + onError: (error: any) => { + if (error.response?.data?.errors && editorForm) { + const errors = error.response.data.errors; + + // Check if body field has syntax error + if (errors.body) { + // Set form error for the body field and show specific message + editorForm.setFieldError('body', t`Invalid Liquid syntax. Please correct it and try again.`); + showError(t`The template body contains invalid Liquid syntax. Please correct it and try again.`); + } else { + // Handle other field errors normally + handleFormError(editorForm, error, t`Failed to save template`); + } + } else { + showError(t`Failed to save template`); + } + + onError?.(error, t`Failed to save template`); + }, + }); + } else { + const params = contextType === 'event' + ? {eventId: contextId, templateData: data as CreateEmailTemplateRequest} + : {organizerId: contextId, templateData: data as CreateEmailTemplateRequest}; + + createMutation.mutate(params, { + onSuccess: () => { + showSuccess(t`Template created successfully`); + closeEditor(); + onSaveSuccess?.(); + }, + onError: (error: any) => { + if (error.response?.data?.errors && editorForm) { + const errors = error.response.data.errors; + + // Check if body field has syntax error + if (errors.body) { + // Set form error for the body field and show specific message + editorForm.setFieldError('body', t`Invalid Liquid syntax. Please correct it and try again.`); + showError(t`The template body contains invalid Liquid syntax. Please correct it and try again.`); + } else { + // Handle other field errors normally + handleFormError(editorForm, error, t`Failed to save template`); + } + } else { + showError(t`Failed to create template`); + } + + onError?.(error, t`Failed to create template`); + }, + }); + } + }; + + const handlePreviewTemplate = (data: { subject: string; body: string; template_type: EmailTemplateType; ctaLabel: string }) => { + const params = contextType === 'event' + ? {eventId: contextId, previewData: data} + : {organizerId: contextId, previewData: data}; + + previewMutation.mutate(params); + }; + + const templateTypeLabels: Record = { + 'order_confirmation': t`Order Confirmation`, + 'attendee_ticket': t`Attendee Ticket`, + }; + + const templateDescriptions: Record = { + 'order_confirmation': t`Sent to customers when they place an order`, + 'attendee_ticket': t`Sent to each attendee with their ticket details`, + }; + + const getTemplateStatusBadge = (template?: EmailTemplate) => { + if (!template) { + const fallbackText = contextType === 'event' + ? t`Organizer/default template will be used` + : t`Default template will be used`; + return ( + + {fallbackText} + + ); + } + + return ( + + {contextType === 'event' ? t`Event custom template` : t`Custom template`} + + ); + }; + + const TemplateCard = ({ + type, + template, + label, + description + }: { + type: EmailTemplateType; + template?: EmailTemplate; + label: string; + description: string; + }) => ( + + +
+ + + {label} + {getTemplateStatusBadge(template)} + + + {description} + + + {template && ( + +
+ + Subject: + + + {template.subject} + +
+
+ + {template.is_active ? t`Active` : t`Inactive`} + +
+
+ )} +
+ + + {template ? ( + <> + handleEditTemplate(template)} + > + + + handleDeleteTemplate(template)} + loading={deleteMutation.isPending} + > + + + + ) : ( + + )} + +
+
+ ); + + const getHeadingDescription = () => { + if (contextType === 'event') { + return t`Create custom email templates for this event that override the organizer defaults`; + } + return t`Customize the emails sent to your customers using Liquid templating. These templates will be used as defaults for all events in your organization.`; + }; + + const getAlertMessage = () => { + if (contextType === 'event') { + return ( + + These templates will override the organizer defaults for this event only. + If no custom template is set here, the organizer template will be used instead. + + ); + } + return ( + + These templates will be used as defaults for all events in your organization. + Individual events can override these templates with their own custom versions. + + ); + }; + + return ( + + + + } variant="light" mb="lg"> + + {getAlertMessage()} + + + +
+ + + + + + + +
+ + + + +
+ ); +}; diff --git a/frontend/src/components/common/EmailTemplateSettings/index.tsx b/frontend/src/components/common/EmailTemplateSettings/index.tsx new file mode 100644 index 0000000000..3f58c040e2 --- /dev/null +++ b/frontend/src/components/common/EmailTemplateSettings/index.tsx @@ -0,0 +1 @@ +export { EmailTemplateSettingsBase } from './EmailTemplateSettingsBase'; \ No newline at end of file diff --git a/frontend/src/components/common/GlobalMenu/index.tsx b/frontend/src/components/common/GlobalMenu/index.tsx index 4fe396e5d5..d8b0f38cf2 100644 --- a/frontend/src/components/common/GlobalMenu/index.tsx +++ b/frontend/src/components/common/GlobalMenu/index.tsx @@ -68,9 +68,9 @@ export const GlobalMenu = () => { links.push({ label: t`Logout`, icon: IconLogout, - onClick: (event: any) => { + onClick: async (event: any) => { event.preventDefault(); - authClient.logout(); + await authClient.logout(); localStorage.removeItem("token"); window.location.href = "/auth/login"; }, diff --git a/frontend/src/components/common/LanguageSwitcher/index.tsx b/frontend/src/components/common/LanguageSwitcher/index.tsx index 56d519a307..b200afd574 100644 --- a/frontend/src/components/common/LanguageSwitcher/index.tsx +++ b/frontend/src/components/common/LanguageSwitcher/index.tsx @@ -32,6 +32,8 @@ export const LanguageSwitcher = () => { return t`Chinese (Traditional)`; case "vi": return t`Vietnamese`; + case "tr": + return t`Turkish`; } }; diff --git a/frontend/src/components/common/PoweredByFooter/index.tsx b/frontend/src/components/common/PoweredByFooter/index.tsx index b7ea50f5a2..0f8013dcfb 100644 --- a/frontend/src/components/common/PoweredByFooter/index.tsx +++ b/frontend/src/components/common/PoweredByFooter/index.tsx @@ -1,8 +1,9 @@ import {t} from "@lingui/macro"; import classes from "./FloatingPoweredBy.module.scss"; import classNames from "classnames"; -import React from "react"; +import React, {useMemo} from "react"; import {iHavePurchasedALicence, isHiEvents} from "../../../utilites/helpers.ts"; +import {getConfig} from "../../../utilites/config.ts"; /** * (c) Hi.Events Ltd 2025 @@ -17,29 +18,54 @@ import {iHavePurchasedALicence, isHiEvents} from "../../../utilites/helpers.ts"; * * If you wish to remove this notice, a commercial license is available at: https://hi.events/licensing */ -export const PoweredByFooter = (props: React.DetailedHTMLProps, HTMLDivElement>) => { +export const PoweredByFooter = ( + props: React.DetailedHTMLProps, HTMLDivElement> +) => { if (iHavePurchasedALicence()) { return <>; } + const link = useMemo(() => { + let host = getConfig("VITE_FRONTEND_URL") ?? "unknown"; + let medium = "app"; + + if (typeof window !== "undefined" && window.location) { + host = window.location.hostname; + medium = window.location.pathname.includes("/widget") ? "widget" : "app"; + } + + const url = new URL("https://hi.events"); + url.searchParams.set("utm_source", "app-powered-by-footer"); + url.searchParams.set("utm_medium", isHiEvents() ? medium : 'self-hosted-' + medium); + url.searchParams.set("utm_campaign", "powered-by"); + url.searchParams.set("utm_content", isHiEvents() ? "hi.events" : host); + + return url.toString(); + }, []); + const footerContent = isHiEvents() ? ( <> - {t`Planning an event?`} {' '} - + {t`Planning an event?`}{" "} + {t`Try Hi.Events Free`} ) : ( <> - {t`Powered by`} {' '} - + {t`Powered by`}{" "} + Hi.Events - 🚀 + {" "} + 🚀 ); diff --git a/frontend/src/components/common/ShareIcon/index.tsx b/frontend/src/components/common/ShareIcon/index.tsx index 94de09d7f5..d60afbeccc 100644 --- a/frontend/src/components/common/ShareIcon/index.tsx +++ b/frontend/src/components/common/ShareIcon/index.tsx @@ -19,6 +19,7 @@ interface ShareComponentProps { imageUrl?: string; shareButtonText?: string; hideShareButtonText?: boolean; + className?: string; } export const ShareComponent = ({ @@ -27,6 +28,7 @@ export const ShareComponent = ({ url, shareButtonText = t`Share`, hideShareButtonText = false, + className, }: ShareComponentProps) => { const [opened, setOpened] = useState(false); @@ -60,13 +62,13 @@ export const ShareComponent = ({
{hideShareButtonText && ( - + )} {!hideShareButtonText && ( - )}
diff --git a/frontend/src/components/common/StatBoxes/index.tsx b/frontend/src/components/common/StatBoxes/index.tsx index 01fbe17390..7c41ff5c6b 100644 --- a/frontend/src/components/common/StatBoxes/index.tsx +++ b/frontend/src/components/common/StatBoxes/index.tsx @@ -40,18 +40,18 @@ export const StatBoxes = () => { const {data: eventStats} = eventStatsQuery; const data = [ - { - 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: formatNumber(eventStats?.total_products_sold as number), + description: t`Products sold`, + icon: , + backgroundColor: '#4B7BE5' // Deep blue + }, { number: formatCurrency(eventStats?.total_refunded as number || 0, event?.currency), description: t`Refunded`, diff --git a/frontend/src/components/common/StripeConnectButton/index.tsx b/frontend/src/components/common/StripeConnectButton/index.tsx index 645509f59b..77ee3f0c8f 100644 --- a/frontend/src/components/common/StripeConnectButton/index.tsx +++ b/frontend/src/components/common/StripeConnectButton/index.tsx @@ -5,6 +5,7 @@ import { t } from '@lingui/macro'; import { useCreateOrGetStripeConnectDetails } from '../../../queries/useCreateOrGetStripeConnectDetails'; import { useGetAccount } from '../../../queries/useGetAccount'; import { showSuccess } from '../../../utilites/notifications'; +import { isHiEvents } from '../../../utilites/helpers'; interface StripeConnectButtonProps { buttonText?: string; @@ -13,6 +14,7 @@ interface StripeConnectButtonProps { size?: string; fullWidth?: boolean; className?: string; + platform?: string; } export const StripeConnectButton: React.FC = ({ @@ -21,16 +23,22 @@ export const StripeConnectButton: React.FC = ({ variant = 'light', size = 'sm', fullWidth = false, - className + className, + platform }) => { const [fetchStripeDetails, setFetchStripeDetails] = useState(false); const [isReturningFromStripe, setIsReturningFromStripe] = useState(false); const accountQuery = useGetAccount(); const account = accountQuery.data; + // For Hi.Events, use the new platform parameter for Ireland migration + // For open-source, use existing logic (no platform parameter) + const platformToUse = isHiEvents() ? platform || 'ie' : undefined; + const stripeDetailsQuery = useCreateOrGetStripeConnectDetails( account?.id || '', - (!!account?.stripe_account_id || fetchStripeDetails) && !!account?.id + (!!account?.stripe_account_id || fetchStripeDetails) && !!account?.id, + platformToUse ); const stripeDetails = stripeDetailsQuery.data; diff --git a/frontend/src/components/forms/PromoCodeForm/index.tsx b/frontend/src/components/forms/PromoCodeForm/index.tsx index eb24479bed..a8ab17d2f1 100644 --- a/frontend/src/components/forms/PromoCodeForm/index.tsx +++ b/frontend/src/components/forms/PromoCodeForm/index.tsx @@ -88,7 +88,7 @@ export const PromoCodeForm = ({form}: PromoCodeFormProps) => { } + rightSection={} {...form.getInputProps('discount')} label={(form.values.discount_type === 'PERCENTAGE' ? t`Discount %` : t`Discount in ${event.currency}`)} placeholder="0.00"/> diff --git a/frontend/src/components/layouts/AppLayout/Topbar/Topbar.module.scss b/frontend/src/components/layouts/AppLayout/Topbar/Topbar.module.scss index c5ea2149f8..d770feed1a 100644 --- a/frontend/src/components/layouts/AppLayout/Topbar/Topbar.module.scss +++ b/frontend/src/components/layouts/AppLayout/Topbar/Topbar.module.scss @@ -135,6 +135,7 @@ font-size: 1.2em; display: flex; justify-content: center; + padding-inline: 5px; a { display: flex; diff --git a/frontend/src/components/layouts/AppLayout/Topbar/index.tsx b/frontend/src/components/layouts/AppLayout/Topbar/index.tsx index b28053e093..923c2dc855 100644 --- a/frontend/src/components/layouts/AppLayout/Topbar/index.tsx +++ b/frontend/src/components/layouts/AppLayout/Topbar/index.tsx @@ -5,6 +5,7 @@ import {IconHome} from "@tabler/icons-react"; import classes from './Topbar.module.scss'; import {BreadcrumbItem} from "../types"; import {GlobalMenu} from "../../../common/GlobalMenu"; +import { getConfig } from "../../../../utilites/config"; interface TopbarProps { sidebarOpen: boolean; @@ -37,8 +38,8 @@ export const Topbar: React.FC = ({ />
- - {''}/ + + {`${getConfig("VITE_APP_NAME",
diff --git a/frontend/src/components/layouts/CheckIn/CheckIn.module.scss b/frontend/src/components/layouts/CheckIn/CheckIn.module.scss index 054664b69c..fbfb267234 100644 --- a/frontend/src/components/layouts/CheckIn/CheckIn.module.scss +++ b/frontend/src/components/layouts/CheckIn/CheckIn.module.scss @@ -33,7 +33,7 @@ .searchBar { display: flex; - gap: 20px; + gap: 6px; align-items: center; .searchInput { @@ -79,6 +79,9 @@ .details { flex: 1; + gap: 5px; + display: flex; + flex-direction: column; .awaitingPayment { margin: 3px 0; @@ -88,10 +91,11 @@ .product { color: #999; - font-size: 0.9em; + font-size: 1em; display: flex; align-items: center; gap: 5px; + vertical-align: middle; } } diff --git a/frontend/src/components/layouts/CheckIn/index.tsx b/frontend/src/components/layouts/CheckIn/index.tsx index c2cdd42174..91de296d60 100644 --- a/frontend/src/components/layouts/CheckIn/index.tsx +++ b/frontend/src/components/layouts/CheckIn/index.tsx @@ -1,22 +1,15 @@ import {useParams} from "react-router"; import {useGetCheckInListPublic} from "../../../queries/useGetCheckInListPublic.ts"; -import {useState} from "react"; +import {useCallback, useEffect, useRef, useState} from "react"; import {useDebouncedValue, useDisclosure, useNetwork} from "@mantine/hooks"; -import {Attendee, QueryFilters} from "../../../types.ts"; +import {Attendee, QueryFilters, QueryFilterOperator} from "../../../types.ts"; import {showError, showSuccess} from "../../../utilites/notifications.tsx"; import {t, Trans} from "@lingui/macro"; import {AxiosError} from "axios"; import classes from "./CheckIn.module.scss"; -import {ActionIcon, Alert, Button, Loader, Modal, Progress, Stack} from "@mantine/core"; +import {ActionIcon, Modal} from "@mantine/core"; import {SearchBar} from "../../common/SearchBar"; -import { - IconAlertCircle, - IconCreditCard, - IconInfoCircle, - IconQrcode, - IconTicket, - IconUserCheck -} from "@tabler/icons-react"; +import {IconInfoCircle, IconQrcode, IconVolume, IconVolumeOff} from "@tabler/icons-react"; import {QRScannerComponent} from "../../common/AttendeeCheckInTable/QrScanner.tsx"; import {useGetCheckInListAttendees} from "../../../queries/useGetCheckInListAttendeesPublic.ts"; import {useCreateCheckInPublic} from "../../../mutations/useCreateCheckInPublic.ts"; @@ -26,6 +19,13 @@ import {Countdown} from "../../common/Countdown"; import Truncate from "../../common/Truncate"; import {Header} from "../../common/Header"; import {publicCheckInClient} from "../../../api/check-in.client.ts"; +import {isSsr} from "../../../utilites/helpers.ts"; +import {AttendeeList} from "../../common/CheckIn/AttendeeList"; +import {CheckInOptionsModal} from "../../common/CheckIn/CheckInOptionsModal"; +import {ScannerSelectionModal} from "../../common/CheckIn/ScannerSelectionModal"; +import {CheckInInfoModal} from "../../common/CheckIn/CheckInInfoModal"; +import {HidScannerStatus} from "../../common/CheckIn/HidScannerStatus"; +import {Button} from "@mantine/core"; const CheckIn = () => { const networkStatus = useNetwork(); @@ -37,6 +37,22 @@ const CheckIn = () => { const [searchQuery, setSearchQuery] = useState(''); const [searchQueryDebounced] = useDebouncedValue(searchQuery, 200); const [qrScannerOpen, setQrScannerOpen] = useState(false); + const [scannerSelectionOpen, setScannerSelectionOpen] = useState(false); + const [hidScannerMode, setHidScannerMode] = useState(false); + const [currentBarcode, setCurrentBarcode] = useState(''); + const [pageHasFocus, setPageHasFocus] = useState(true); + const barcodeTimeoutRef = useRef(null); + const isProcessingRef = useRef(false); + const processedBarcodesRef = useRef>(new Set()); + const lastScanTimeRef = useRef(0); + const scanSuccessAudioRef = useRef(null); + const scanErrorAudioRef = useRef(null); + const [isSoundOn, setIsSoundOn] = useState(() => { + if (isSsr()) return true; + // Use a unified sound setting for all scanners + const storedIsSoundOn = localStorage.getItem("scannerSoundOn"); + return storedIsSoundOn === null ? true : JSON.parse(storedIsSoundOn); + }); const [selectedAttendee, setSelectedAttendee] = useState(null); const [checkInModalOpen, checkInModalHandlers] = useDisclosure(false); const [infoModalOpen, infoModalHandlers] = useDisclosure(false, { @@ -52,7 +68,7 @@ const CheckIn = () => { query: searchQueryDebounced, perPage: 150, filterFields: { - status: {operator: 'eq', value: 'ACTIVE'}, + status: {operator: QueryFilterOperator.Equals, value: 'ACTIVE'}, }, }; @@ -68,6 +84,40 @@ const CheckIn = () => { const allowOrdersAwaitingOfflinePaymentToCheckIn = areOfflinePaymentsEnabled && eventSettings?.allow_orders_awaiting_offline_payment_to_check_in; + // Save sound preference to localStorage + useEffect(() => { + if (!isSsr()) { + localStorage.setItem("scannerSoundOn", JSON.stringify(isSoundOn)); + } + }, [isSoundOn]); + + // Sound helpers + const playSuccessSound = useCallback(() => { + if (isSoundOn && scanSuccessAudioRef.current) { + scanSuccessAudioRef.current.play().catch(() => { + // Ignore audio play errors (e.g., user hasn't interacted with page) + }); + } + }, [isSoundOn]); + + const playErrorSound = useCallback(() => { + if (isSoundOn && scanErrorAudioRef.current) { + scanErrorAudioRef.current.play().catch(() => { + // Ignore audio play errors (e.g., user hasn't interacted with page) + }); + } + }, [isSoundOn]); + + const playClickSound = useCallback(() => { + if (isSoundOn && scanSuccessAudioRef.current) { + // Use success sound for click feedback + scanSuccessAudioRef.current.currentTime = 0; // Reset to start for quick successive clicks + scanSuccessAudioRef.current.play().catch(() => { + // Ignore audio play errors + }); + } + }, [isSoundOn]); + const handleCheckInAction = (attendee: Attendee, action: 'check-in' | 'check-in-and-mark-order-as-paid') => { checkInMutation.mutate({ checkInListShortId: checkInListShortId, @@ -77,20 +127,23 @@ const CheckIn = () => { onSuccess: ({errors}) => { if (errors && errors[attendee.public_id]) { showError(errors[attendee.public_id]); + playErrorSound(); return; } showSuccess({attendee.first_name} checked in successfully); + playSuccessSound(); checkInModalHandlers.close(); setSelectedAttendee(null); }, onError: (error) => { + playErrorSound(); if (!networkStatus.online) { showError(t`You are offline`); return; } if (error instanceof AxiosError) { - showError(error?.response?.data.message || t`Unable to check in attendee`); + showError(error?.response?.data?.message || t`Unable to check in attendee`); } } }); @@ -104,14 +157,20 @@ const CheckIn = () => { }, { onSuccess: () => { showSuccess({attendee.first_name} checked out successfully); + playSuccessSound(); }, onError: (error) => { + playErrorSound(); if (!networkStatus.online) { showError(t`You are offline`); return; } - showError(error?.response?.data.message || t`Unable to check out attendee`); + if (error instanceof AxiosError) { + showError(error?.response?.data?.message || t`Unable to check out attendee`); + } else { + showError(t`Unable to check out attendee`); + } } }); return; @@ -133,7 +192,24 @@ const CheckIn = () => { handleCheckInAction(attendee, 'check-in'); }; - const handleQrCheckIn = async (attendeePublicId: string) => { + const handleQrCheckIn = useCallback(async (attendeePublicId: string) => { + // Prevent processing if already handling a request + if (isProcessingRef.current) { + return; + } + + // Check if this barcode was recently processed (within last 3 seconds) + const now = Date.now(); + if (processedBarcodesRef.current.has(attendeePublicId) && + now - lastScanTimeRef.current < 3000) { + showError(t`This ticket was just scanned. Please wait before scanning again.`); + playErrorSound(); + return; + } + + isProcessingRef.current = true; + lastScanTimeRef.current = now; + // Find the attendee in the current list or fetch them let attendee = attendees?.find(a => a.public_id === attendeePublicId); @@ -143,169 +219,132 @@ const CheckIn = () => { attendee = data; } catch (error) { showError(t`Unable to fetch attendee`); + playErrorSound(); + isProcessingRef.current = false; return; } if (!attendee) { showError(t`Attendee not found`); + playErrorSound(); + isProcessingRef.current = false; return; } } + // Check if already checked in + if (attendee.check_in) { + showError({attendee.first_name} {attendee.last_name} is already checked in); + playErrorSound(); + processedBarcodesRef.current.add(attendeePublicId); + isProcessingRef.current = false; + return; + } + const isAttendeeAwaitingPayment = attendee.status === 'AWAITING_PAYMENT'; if (allowOrdersAwaitingOfflinePaymentToCheckIn && isAttendeeAwaitingPayment) { setSelectedAttendee(attendee); checkInModalHandlers.open(); + isProcessingRef.current = false; return; } if (!allowOrdersAwaitingOfflinePaymentToCheckIn && isAttendeeAwaitingPayment) { showError(t`You cannot check in attendees with unpaid orders. This setting can be changed in the event settings.`); + playErrorSound(); + isProcessingRef.current = false; return; } - handleCheckInAction(attendee, 'check-in'); - }; + // Add to processed set before making the request + processedBarcodesRef.current.add(attendeePublicId); - const checkInButtonText = (attendee: Attendee) => { - if (!allowOrdersAwaitingOfflinePaymentToCheckIn && attendee.status === 'AWAITING_PAYMENT') { - return t`Cannot Check In`; - } + // Clear old entries from the set after 10 seconds + setTimeout(() => { + processedBarcodesRef.current.delete(attendeePublicId); + }, 10000); - if (attendee.check_in) { - return t`Check Out`; - } + await handleCheckInAction(attendee, 'check-in'); + isProcessingRef.current = false; + }, [attendees, checkInListShortId, allowOrdersAwaitingOfflinePaymentToCheckIn, checkInModalHandlers, handleCheckInAction, playErrorSound]); - return t`Check In`; - } - const CheckInOptionsModal = () => { - if (!selectedAttendee) return null; + // Process completed barcode + const processBarcode = useCallback((barcode: string) => { + if (barcode.startsWith('A-') && barcode.length > 3) { + handleQrCheckIn(barcode); + } + }, [handleQrCheckIn]); + + // Track page focus + useEffect(() => { + const handleFocus = () => setPageHasFocus(true); + const handleBlur = () => setPageHasFocus(false); + + window.addEventListener('focus', handleFocus); + window.addEventListener('blur', handleBlur); + + return () => { + window.removeEventListener('focus', handleFocus); + window.removeEventListener('blur', handleBlur); + }; + }, []); + + // Global keyboard listener for HID scanner mode + useEffect(() => { + if (!hidScannerMode) return; + + const handleKeyPress = (e: KeyboardEvent) => { + // Ignore if user is typing in an input field + if (e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement) { + return; + } - return ( - { - checkInModalHandlers.close(); - setSelectedAttendee(null); - }} - title={Check in {selectedAttendee.first_name} {selectedAttendee.last_name}} - size="md" - > - - } - variant={'light'} - title={t`Unpaid Order`}> - {t`This attendee has an unpaid order.`} - - - - - - - ); - }; + if (e.key === 'Enter') { + // Process the accumulated barcode on Enter + if (currentBarcode.length > 0) { + processBarcode(currentBarcode); + setCurrentBarcode(''); + } + } else if (e.key.length === 1) { + // Accumulate characters + setCurrentBarcode(prev => { + const newBarcode = prev + e.key; + + // Clear any existing timeout + if (barcodeTimeoutRef.current) { + clearTimeout(barcodeTimeoutRef.current); + } - const Attendees = () => { - const Container = () => { - if (attendeesQuery.isFetching || !attendees || !products) { - return ( -
- -
- ) + // Set timeout to clear barcode if no more input (scanner stopped) + barcodeTimeoutRef.current = setTimeout(() => { + // Auto-process if it looks like a complete barcode + if (newBarcode.startsWith('A-') && newBarcode.length > 3) { + processBarcode(newBarcode); + } + setCurrentBarcode(''); + }, 100); + + return newBarcode; + }); } + }; - if (attendees.length === 0) { - return ( -
- No attendees to show. -
- ); + window.addEventListener('keypress', handleKeyPress); + + return () => { + window.removeEventListener('keypress', handleKeyPress); + if (barcodeTimeoutRef.current) { + clearTimeout(barcodeTimeoutRef.current); } + }; + }, [hidScannerMode, currentBarcode, processBarcode]); - return ( -
- {attendees.map(attendee => { - const isAttendeeAwaitingPayment = attendee.status === 'AWAITING_PAYMENT'; - - return ( -
-
-
- {attendee.first_name} {attendee.last_name} -
- {isAttendeeAwaitingPayment && ( -
- {t`Awaiting payment`} -
- )} -
- {attendee.public_id} -
-
- {products.find(product => product.id === attendee.product_id)?.title} -
-
-
- {} -
-
- ) - })} -
- ) - } - return ( -
- -
- ); - } - if (CheckInListQuery.error && CheckInListQuery.error.response?.status === 404) { + if (CheckInListQuery.error && (CheckInListQuery.error as any).response?.status === 404) { return ( {
)} infoModalHandlers.open()} > )}/> + setHidScannerMode(false)} + />

@@ -394,19 +441,60 @@ const CheckIn = () => { placeholder={t`Search by name, order #, attendee # or email...`} /> - setIsSoundOn(!isSoundOn)} + > + {isSoundOn ? : } + + setQrScannerOpen(true)}> + onClick={() => setScannerSelectionOpen(true)}>

- - + + { + checkInModalHandlers.close(); + setSelectedAttendee(null); + }} + onCheckIn={(action) => selectedAttendee && handleCheckInAction(selectedAttendee, action)} + /> + setScannerSelectionOpen(false)} + onCameraSelect={() => { + setScannerSelectionOpen(false); + setQrScannerOpen(true); + }} + onHidScannerSelect={() => { + setScannerSelectionOpen(false); + if (!hidScannerMode) { + setHidScannerMode(true); + } + }} + /> {qrScannerOpen && ( { setQrScannerOpen(false)} + isSoundOn={isSoundOn} /> )} - {infoModalOpen && ( - - - - - - - - - -
-
- {checkInList && ( - <> -

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

- - - - - )} -
- - {checkInList?.description && ( -
- {checkInList.description} -
- )} -
-
-
- )} + + {/* Audio elements for HID scanner sounds */} +
); } diff --git a/frontend/src/components/layouts/Event/index.tsx b/frontend/src/components/layouts/Event/index.tsx index 6f8ab89b90..9ae2507fd6 100644 --- a/frontend/src/components/layouts/Event/index.tsx +++ b/frontend/src/components/layouts/Event/index.tsx @@ -96,7 +96,7 @@ const EventLayout = () => { {label: t`Manage`}, {link: 'settings', label: t`Settings`, icon: IconSettings}, - {link: 'attendees', label: t`Attendees`, icon: IconUsers, badge: eventStats?.total_products_sold}, + {link: 'attendees', label: t`Attendees`, icon: IconUsers, badge: eventStats?.total_attendees_registered}, {link: 'orders', label: t`Orders`, icon: IconReceipt, badge: eventStats?.total_orders}, {link: 'products', label: t`Tickets & Products`, icon: IconTicket}, {link: 'questions', label: t`Questions`, icon: IconUserQuestion}, @@ -108,6 +108,7 @@ const EventLayout = () => { {label: t`Tools`}, {link: 'homepage-designer', label: t`Homepage Designer`, icon: IconPaint}, + {link: 'ticket-designer', label: t`Ticket Design`, icon: IconTicket}, {link: 'widget', label: t`Widget Embed`, icon: IconDeviceTabletCode}, {link: 'webhooks', label: t`Webhooks`, icon: IconWebhook}, ]; diff --git a/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss b/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss index 5f96e16eb1..31babd4def 100644 --- a/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss +++ b/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss @@ -185,7 +185,6 @@ height: 80px; border-radius: 12px; object-fit: contain; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); @include mixins.respond-below(sm) { width: 60px; diff --git a/frontend/src/components/layouts/OrganizerHomepage/EventCard/EventCard.module.scss b/frontend/src/components/layouts/OrganizerHomepage/EventCard/EventCard.module.scss index 395dcb6b26..943c23a538 100644 --- a/frontend/src/components/layouts/OrganizerHomepage/EventCard/EventCard.module.scss +++ b/frontend/src/components/layouts/OrganizerHomepage/EventCard/EventCard.module.scss @@ -203,6 +203,16 @@ } } +.shareIcon { + color: var(--secondary-color); + transition: all 0.2s ease; + + &:hover { + transform: scale(1.2); + color: var(--secondary-color); + } +} + // Live indicator .liveIndicator { display: flex; diff --git a/frontend/src/components/layouts/OrganizerHomepage/EventCard/index.tsx b/frontend/src/components/layouts/OrganizerHomepage/EventCard/index.tsx index fd7d63baf7..25c1ecb8e9 100644 --- a/frontend/src/components/layouts/OrganizerHomepage/EventCard/index.tsx +++ b/frontend/src/components/layouts/OrganizerHomepage/EventCard/index.tsx @@ -117,6 +117,7 @@ export const EventCard: React.FC = ({event, primaryColor = '#8b5 text={event.description_preview || ''} url={eventHomepageUrl(event)} hideShareButtonText={true} + className={classes.shareIcon} />
diff --git a/frontend/src/components/layouts/OrganizerHomepage/index.tsx b/frontend/src/components/layouts/OrganizerHomepage/index.tsx index a49808492a..bb86c2d638 100644 --- a/frontend/src/components/layouts/OrganizerHomepage/index.tsx +++ b/frontend/src/components/layouts/OrganizerHomepage/index.tsx @@ -311,7 +311,7 @@ export const OrganizerHomepage = ({ control: { backgroundColor: 'var(--content-bg-color)', border: '1px solid rgba(0, 0, 0, 0.1)', - color: 'var(--secondary-text-color)', + color: 'var(--primary-text-color)', fontSize: '0.875rem', fontWeight: 500, borderRadius: '8px', diff --git a/frontend/src/components/modals/CancelOrderModal/index.tsx b/frontend/src/components/modals/CancelOrderModal/index.tsx index 2df1ae9409..24c9cdb91e 100644 --- a/frontend/src/components/modals/CancelOrderModal/index.tsx +++ b/frontend/src/components/modals/CancelOrderModal/index.tsx @@ -3,7 +3,7 @@ import {useParams} from "react-router"; import {useGetEvent} from "../../../queries/useGetEvent.ts"; import {useGetOrder} from "../../../queries/useGetOrder.ts"; import {Modal} from "../../common/Modal"; -import {Alert, Button, LoadingOverlay} from "@mantine/core"; +import {Alert, Button, Checkbox, LoadingOverlay} from "@mantine/core"; import {IconInfoCircle} from "@tabler/icons-react"; import classes from './CancelOrderModal.module.scss'; import {OrderDetails} from "../../common/OrderDetails"; @@ -11,6 +11,7 @@ import {AttendeeList} from "../../common/AttendeeList"; import {t} from "@lingui/macro"; import {useCancelOrder} from "../../../mutations/useCancelOrder.ts"; import {showError, showSuccess} from "../../../utilites/notifications.tsx"; +import {useState} from "react"; interface RefundOrderModalProps extends GenericModalProps { orderId: IdParam, @@ -18,15 +19,27 @@ interface RefundOrderModalProps extends GenericModalProps { export const CancelOrderModal = ({onClose, orderId}: RefundOrderModalProps) => { const {eventId} = useParams(); - // const queryClient = useQueryClient(); const {data: order} = useGetOrder(eventId, orderId); const {data: event, data: {products} = {}} = useGetEvent(eventId); const cancelOrderMutation = useCancelOrder(); + const [shouldRefund, setShouldRefund] = useState(true); + + const isRefundable = order && !order.is_free_order + && order.status !== 'AWAITING_OFFLINE_PAYMENT' + && order.payment_provider === 'STRIPE' + && order.refund_status !== 'REFUNDED'; const handleCancelOrder = () => { - cancelOrderMutation.mutate({eventId, orderId}, { + cancelOrderMutation.mutate({ + eventId, + orderId, + refund: shouldRefund && isRefundable + }, { onSuccess: () => { - showSuccess(t`Order has been canceled and the order owner has been notified.`); + const message = shouldRefund && isRefundable + ? t`Order has been canceled and refunded. The order owner has been notified.` + : t`Order has been canceled and the order owner has been notified.`; + showSuccess(message); onClose(); }, onError: (error: any) => { @@ -51,9 +64,20 @@ export const CancelOrderModal = ({onClose, orderId}: RefundOrderModalProps) => { }> - {t`Canceling will cancel all products associated with this order, and release the products back into the available pool.`} + {t`Canceling will cancel all attendees associated with this order, and release the tickets back into the available pool.`} + {isRefundable && ( + setShouldRefund(event.currentTarget.checked)} + label={t`Also refund this order`} + description={t`The full order amount will be refunded to the customer's original payment method.`} + /> + )} + + + + + + {t`Order Total`} + + + + + {order.total_refunded > 0 && ( + + {t`Already Refunded`} + + - + + + )} + + {t`Available to Refund`} + + + + + + + + } + rightSectionWidth={50} + rightSection={{order.currency}} + styles={{ + input: { + fontWeight: 600, + fontSize: '1.1rem' + } + }} + /> + + + + {order.status !== 'CANCELLED' && ( + + )} + + + {isPartialRefund && ( + } color="blue" variant="light"> + {t`You are issuing a partial refund. The customer will be refunded ${form.values.amount.toFixed(2)} ${order.currency}.`} + + )} + + + ); }; const CannotRefund = ({message}: { message: string }) => { return ( - <> - } color={'blue'}> + + } + color={'blue'} + variant="light" + styles={{ + message: {fontSize: '0.95rem'} + }} + > {message} - - + + ) } @@ -148,7 +191,13 @@ export const RefundOrderModal = ({onClose, orderId}: RefundOrderModalProps) => { + + {t`Refund Order ${order?.public_id || ''}`} + + } + size="md" padding={'lg'} overlayProps={{ opacity: 0.55, diff --git a/frontend/src/components/modals/SendMessageModal/index.tsx b/frontend/src/components/modals/SendMessageModal/index.tsx index 27937abeef..5a23f0ccc1 100644 --- a/frontend/src/components/modals/SendMessageModal/index.tsx +++ b/frontend/src/components/modals/SendMessageModal/index.tsx @@ -15,7 +15,7 @@ import {useSendEventMessage} from "../../../mutations/useSendEventMessage.ts"; import {ProductSelector} from "../../common/ProductSelector"; import {useEffect} from "react"; import {useGetAccount} from "../../../queries/useGetAccount.ts"; -import { getConfig } from "../../../utilites/config.ts"; +import {StripeConnectButton} from "../../common/StripeConnectButton"; interface EventMessageModalProps extends GenericModalProps { orderId?: IdParam, @@ -156,17 +156,11 @@ export const SendMessageModal = (props: EventMessageModalProps) => { {accountRequiresManualVerification && ( <> } - title={t`Contact us to enable messaging`}> - {t`Due to the high risk of spam, we require manual verification before you can send messages. - Please contact us to request access.`} - + title={t`Connect Stripe to enable messaging`}> + {t`Due to the high risk of spam, you must connect a Stripe account before you can send messages to attendees. + This is to ensure that all event organizers are verified and accountable.`} +

+
diff --git a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/PaymentSettings.module.scss b/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/PaymentSettings.module.scss index 311e41ea8f..99d7c58a01 100644 --- a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/PaymentSettings.module.scss +++ b/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/PaymentSettings.module.scss @@ -12,3 +12,34 @@ } } } + +.migrationNotice { + background: var(--mantine-color-blue-0); + border: 1px solid var(--mantine-color-blue-2); + border-radius: 8px; + margin-bottom: 20px; +} + +.migrationBanner { + border: 2px solid var(--mantine-color-blue-4); + margin-bottom: 16px; +} + +.platformPanel { + margin-bottom: 16px; + transition: all 0.2s ease; + + &.activePlatform { + border: 2px solid var(--mantine-color-green-5); + background: var(--mantine-color-green-0); + + &.ca { + border-color: var(--mantine-color-orange-5); + background: var(--mantine-color-orange-0); + } + } + + &:not(.activePlatform) { + opacity: 0.85; + } +} diff --git a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/index.tsx b/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/index.tsx index 1957859956..5b359e5266 100644 --- a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/index.tsx +++ b/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/index.tsx @@ -1,18 +1,20 @@ import {t} from "@lingui/macro"; -import {Card} from "../../../../../common/Card"; import {HeadingCard} from "../../../../../common/HeadingCard"; import {useCreateOrGetStripeConnectDetails} from "../../../../../../queries/useCreateOrGetStripeConnectDetails.ts"; import {useGetAccount} from "../../../../../../queries/useGetAccount.ts"; +import {useGetStripeConnectAccounts} from "../../../../../../queries/useGetStripeConnectAccounts.ts"; import {LoadingMask} from "../../../../../common/LoadingMask"; import {Anchor, Button, Grid, Group, Text, ThemeIcon, Title} from "@mantine/core"; -import {Account} from "../../../../../../types.ts"; +import {Account, StripeConnectAccountsResponse} from "../../../../../../types.ts"; import paymentClasses from "./PaymentSettings.module.scss"; import classes from "../../ManageAccount.module.scss"; import {useEffect, useState} from "react"; -import {IconAlertCircle, IconBrandStripe, IconCheck, IconExternalLink} from '@tabler/icons-react'; +import {IconAlertCircle, IconBrandStripe, IconCheck, IconExternalLink, IconInfoCircle} from '@tabler/icons-react'; +import {Card} from "../../../../../common/Card"; import {formatCurrency} from "../../../../../../utilites/currency.ts"; import {showSuccess} from "../../../../../../utilites/notifications.tsx"; -import { getConfig } from "../../../../../../utilites/config.ts"; +import {getConfig} from "../../../../../../utilites/config.ts"; +import {isHiEvents} from "../../../../../../utilites/helpers.ts"; interface FeePlanDisplayProps { configuration?: { @@ -33,6 +35,204 @@ const formatPercentage = (value: number) => { }).format(value / 100); }; +const MigrationNotice = ({stripeData}: { stripeData: StripeConnectAccountsResponse }) => { + const caAccount = stripeData.stripe_connect_accounts.find(acc => acc.platform === 'ca'); + const ieAccount = stripeData.stripe_connect_accounts.find(acc => acc.platform === 'ie'); + + // Only show if Hi.Events user has CA account but no completed IE account + if (!isHiEvents() || !caAccount || (ieAccount && ieAccount.is_setup_complete)) { + return null; + } + + return ( + + + + + + +
+ {t`Action Required: Reconnect Your Stripe Account`} + + + {t`We've officially moved our headquarters to Ireland 🇮🇪. As part of this transition, we're now using Stripe Ireland instead of Stripe Canada. To keep your payouts running smoothly, you'll need to reconnect your Stripe account.`} + + +
+ {t`Here's what to expect:`} + • {t`Takes just a few minutes`} + • {t`No impact on your current or past transactions`} + • {t`Payments will continue to flow without interruption`} +
+ + + {t`Thanks for your support as we continue to grow and improve Hi.Events!`} + +
+
+
+ ); +}; + +const MigrationBanner = ({stripeData}: { stripeData: StripeConnectAccountsResponse }) => { + const caAccount = stripeData.stripe_connect_accounts.find(acc => acc.platform === 'ca'); + const ieAccount = stripeData.stripe_connect_accounts.find(acc => acc.platform === 'ie'); + + // Only show if user has CA account but no completed IE account + if (!isHiEvents() || !caAccount || (ieAccount && ieAccount.is_setup_complete)) { + return null; + } + + return ( + + + + + +
+ {t`Complete the setup below to continue`} +
+
+ + + {t`Just click the button below to reconnect your Stripe account.`} + +
+ ); +}; + +const PlatformPanel = ({ + platform, + account, + isActive, + onSetupStripe, + hideLabels = false, + isMigrationComplete = false, + }: { + platform: 'ca' | 'ie'; + account: any; + isActive: boolean; + onSetupStripe: () => void; + hideLabels?: boolean; + isMigrationComplete?: boolean; +}) => { + const platformColors = { + ca: 'orange', + ie: 'green' + }; + + return ( + + + + + {account?.is_setup_complete ? : } + +
+ {t`Stripe Connect`} + {isActive && !hideLabels && !isMigrationComplete && ( + {t`Current payment processor`} + )} +
+
+ {!hideLabels && platform === 'ca' && isActive && ( + + {t`Upgrade Available`} + + )} +
+ + {account?.is_setup_complete ? ( + <> + + {hideLabels + ? t`Your Stripe account is connected and processing payments.` + : (platform === 'ca' + ? (isActive + ? t`You're all set! Your payments are being processed smoothly.` + : t`Still handling refunds for your older transactions.` + ) + : t`All done! You're now using our upgraded payment system.` + ) + } + + + + + {t`Open Stripe Dashboard`} + + + + + + ) : account ? ( + <> + + {t`Almost there! Finish connecting your Stripe account to start accepting payments.`} + + + + ) : ( + <> + + {hideLabels + ? t`Connect your Stripe account to start accepting payments.` + : (platform === 'ca' + ? t`Connect your Stripe account to accept payments.` + : t`Ready to upgrade? This takes only a few minutes.` + ) + } + + + + )} +
+ ); +}; + const FeePlanDisplay = ({configuration}: FeePlanDisplayProps) => { if (!configuration) return null; @@ -41,7 +241,8 @@ const FeePlanDisplay = ({configuration}: FeePlanDisplayProps) => { {t`Platform Fees`} - {getConfig("VITE_APP_NAME", "Hi.Events")} charges platform fees to maintain and improve our services. These fees are automatically deducted from each transaction. + {getConfig("VITE_APP_NAME", "Hi.Events")} charges platform fees to maintain and improve our services. + These fees are automatically deducted from each transaction. @@ -85,17 +286,293 @@ const FeePlanDisplay = ({configuration}: FeePlanDisplayProps) => { ); }; -const ConnectStatus = ({account}: { account: Account }) => { +// Hi.Events Cloud Multi-Platform Component +const HiEventsConnectStatus = ({account}: { account: Account }) => { + const [fetchStripeDetails, setFetchStripeDetails] = useState(false); + const [platformToSetup, setPlatformToSetup] = useState(); + + const stripeAccountsQuery = useGetStripeConnectAccounts(account.id); + const stripeDetailsQuery = useCreateOrGetStripeConnectDetails( + account.id, + fetchStripeDetails, + platformToSetup + ); + + const stripeData = stripeAccountsQuery.data; + const stripeDetails = stripeDetailsQuery.data; + const error = stripeDetailsQuery.error as any; + + // Check if this is a new user (no platforms set up yet) + const isNewUser = stripeData && + stripeData.stripe_connect_accounts.length === 0 && + !stripeData.account.stripe_platform; + + const handleSetupStripe = (platform: 'ca' | 'ie') => { + setPlatformToSetup(platform); + if (!stripeDetails) { + setFetchStripeDetails(true); + return; + } else if (stripeDetails.connect_url) { + if (typeof window !== 'undefined') { + showSuccess(t`Redirecting to Stripe...`); + window.location.href = stripeDetails.connect_url; + } + } else { + // Setup is already complete, refresh the accounts data + stripeAccountsQuery.refetch(); + } + }; + + useEffect(() => { + if (fetchStripeDetails && !stripeDetailsQuery.isLoading) { + setFetchStripeDetails(false); + if (stripeDetails?.connect_url) { + showSuccess(t`Redirecting to Stripe...`); + window.location.href = stripeDetails.connect_url; + } else if (stripeDetails) { + if (stripeDetails.is_connect_setup_complete) { + showSuccess(t`Account already connected!`); + } + // Refresh the stripe accounts data to get the new account + stripeAccountsQuery.refetch(); + } + } + }, [fetchStripeDetails, stripeDetailsQuery.isFetched, stripeDetails, stripeAccountsQuery]); + + if (error?.response?.status === 403) { + return ( + + + + + + {t`Access Denied`} + + + {error?.response?.data?.message} + + + ); + } + + if (!stripeData) { + return ; + } + + const caAccount = stripeData.stripe_connect_accounts.find(acc => acc.platform === 'ca'); + const ieAccount = stripeData.stripe_connect_accounts.find(acc => acc.platform === 'ie'); + const activePlatform = stripeData.account.stripe_platform; + + // For new Hi.Events users with no CA platform (either new or only IE) + // Show simple setup without migration messaging + if (isNewUser || (!caAccount && ieAccount)) { + const hasIrelandAccount = !!ieAccount; + const isIrelandComplete = ieAccount?.is_setup_complete === true; + + let content; + + if (isIrelandComplete) { + // CASE 1: Ireland account exists and is fully set up + content = ( + <> + + + + + + {t`Connected to Stripe`} + + + + {t`Your Stripe account is connected and ready to process payments.`} + + + + + {t`Open Stripe Dashboard`} + + + + + + ); + } else if (hasIrelandAccount && !isIrelandComplete) { + // CASE 2: Ireland account exists but setup is incomplete + content = ( + <> + + {t`Almost there! Finish connecting your Stripe account to start accepting payments.`} + + + + + + {t`About Stripe Connect`} + + + + + + ); + } else { + // CASE 3: No account exists yet - completely new user + content = ( + <> + + {t`Connect your Stripe account to start accepting payments for your events.`} + + + + + + {t`About Stripe Connect`} + + + + + + ); + } + + return ( +
+ {t`Payment Processing`} + {content} +
+ ); + } + + // Migration logic for users with CA account + const isMigrationComplete = ieAccount?.is_setup_complete === true; + const shouldShowCaAccount = caAccount?.is_setup_complete === true; // Only show CA if it's complete + + return ( +
+ {t`Payment Processing`} + + + + {/* Show active platform first */} + {activePlatform === 'ie' ? ( + <> + {/* Ireland Platform (Active) */} + handleSetupStripe('ie')} + hideLabels={isMigrationComplete && !shouldShowCaAccount} + isMigrationComplete={isMigrationComplete} + /> + + {/* Canada Platform (Legacy) - Only show if complete and migration not done */} + {shouldShowCaAccount && !isMigrationComplete && ( + handleSetupStripe('ca')} + hideLabels={false} + /> + )} + + ) : ( + <> + {/* Canada Platform (Active) - Only show if complete */} + {shouldShowCaAccount && ( + handleSetupStripe('ca')} + isMigrationComplete={isMigrationComplete} + /> + )} + + {/* Ireland Platform - Always show if CA is active (for migration) */} + {shouldShowCaAccount && !isMigrationComplete && ( + handleSetupStripe('ie')} + /> + )} + + {/* If no complete CA account, just show IE setup */} + {!shouldShowCaAccount && ( + handleSetupStripe('ie')} + hideLabels={true} + /> + )} + + )} + + {/* Helpful note during migration only */} + {shouldShowCaAccount && ieAccount && !isMigrationComplete && ( + + {t`Once you complete the upgrade, your old account will only be used for refunds.`} + + )} +
+ ); +}; + +// Open-Source Simple Component (like original) +const OpenSourceConnectStatus = ({account}: { account: Account }) => { const [fetchStripeDetails, setFetchStripeDetails] = useState(false); const [isReturningFromStripe, setIsReturningFromStripe] = useState(false); const stripeDetailsQuery = useCreateOrGetStripeConnectDetails( account.id, - !!account?.stripe_account_id || fetchStripeDetails + !!account?.stripe_account_id || fetchStripeDetails, + undefined // No platform for open-source ); const stripeDetails = stripeDetailsQuery.data; const error = stripeDetailsQuery.error as any; + const handleSetupStripe = () => { + if (!stripeDetails) { + setFetchStripeDetails(true); + return; + } else { + if (typeof window !== 'undefined') { + showSuccess(t`Redirecting to Stripe...`); + window.location.href = String(stripeDetails?.connect_url); + } + } + }; + useEffect(() => { if (typeof window === 'undefined') { return; @@ -111,26 +588,21 @@ const ConnectStatus = ({account}: { account: Account }) => { showSuccess(t`Redirecting to Stripe...`); window.location.href = String(stripeDetails?.connect_url); } - }, [fetchStripeDetails, stripeDetailsQuery.isFetched]); if (error?.response?.status === 403) { return ( - <> - -
- - - - - {t`Access Denied`} - - - {error?.response?.data?.message} - -
-
- + + + + + + {t`Access Denied`} + + + {error?.response?.data?.message} + + ); } @@ -145,9 +617,7 @@ const ConnectStatus = ({account}: { account: Account }) => { - - {t`Connected to Stripe`} - + {t`Connected to Stripe`} @@ -160,7 +630,7 @@ const ConnectStatus = ({account}: { account: Account }) => { size="sm" > - {t`Go to Stripe Dashboard`} + {t`Open Stripe Dashboard`} @@ -187,20 +657,10 @@ const ConnectStatus = ({account}: { account: Account }) => { variant="light" size="sm" leftSection={} - onClick={() => { - if (!stripeDetails) { - setFetchStripeDetails(true); - return; - } else { - if (typeof window !== 'undefined') { - showSuccess(t`Redirecting to Stripe...`); - window.location.href = String(stripeDetails?.connect_url) - } - } - }} + onClick={handleSetupStripe} > {(!isReturningFromStripe && !account?.stripe_account_id) && t`Connect with Stripe`} - {(isReturningFromStripe || account?.stripe_account_id) && t`Complete Stripe Setup`} + {(isReturningFromStripe || account?.stripe_account_id) && t`Finish Stripe Setup`} { ); }; +// Main Component that decides which to show +const ConnectStatus = ({account}: { account: Account }) => { + if (isHiEvents()) { + return ; + } else { + return ; + } +}; + const PaymentSettings = () => { const accountQuery = useGetAccount(); + const stripeAccountsQuery = useGetStripeConnectAccounts( + accountQuery.data?.id || 0, + { + enabled: !!accountQuery.data?.id + } + ); return ( <> @@ -241,9 +716,13 @@ const PaymentSettings = () => { heading={t`Payment Settings`} subHeading={t`Manage your payment processing and view platform fees`} /> + + {/* Migration Notice - Show at the top for Hi.Events users who need to migrate */} + {isHiEvents() && stripeAccountsQuery.data && } + - {(accountQuery.data?.configuration) && ( + {(accountQuery.data) && ( {accountQuery.isFetched && ( diff --git a/frontend/src/components/routes/event/EventDashboard/EventDashboard.module.scss b/frontend/src/components/routes/event/EventDashboard/EventDashboard.module.scss index 784260890b..d2a19a93aa 100644 --- a/frontend/src/components/routes/event/EventDashboard/EventDashboard.module.scss +++ b/frontend/src/components/routes/event/EventDashboard/EventDashboard.module.scss @@ -216,3 +216,140 @@ height: 28px; } } + +.stripeUpgradeCard { + margin: 24px 0; + background: linear-gradient(135deg, #f8f5ff 0%, #fef3fb 100%); + border: 1px solid rgba(139, 92, 246, 0.15); + box-shadow: 0 4px 16px rgba(139, 92, 246, 0.08) !important; + color: #1f2937; + padding: 28px 32px; + border-radius: 16px; + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 200px; + height: 200px; + background: radial-gradient(circle, rgba(139, 92, 246, 0.05) 0%, transparent 70%); + border-radius: 50%; + transform: translate(50%, -50%); + } +} + +.stripeUpgradeContent { + display: flex; + align-items: flex-start; + gap: 24px; + position: relative; + z-index: 1; + + @media (max-width: 768px) { + flex-direction: column; + gap: 20px; + } +} + +.stripeTextContainer { + flex: 1; + display: flex; + flex-direction: column; + gap: 20px; + + @media (max-width: 768px) { + gap: 24px; + } +} + +.stripeIcon { + flex-shrink: 0; + width: 48px; + height: 48px; + background: linear-gradient(135deg, rgba(139, 92, 246, 0.12), rgba(219, 39, 119, 0.1)); + backdrop-filter: blur(10px); + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + color: #7c3aed; + border: 1px solid rgba(139, 92, 246, 0.2); + + svg { + width: 28px; + height: 28px; + } +} + +.stripeText { + flex: 1; + + h3 { + margin: 0 0 8px 0; + font-size: 1.25rem; + font-weight: 600; + color: #1f2937; + letter-spacing: -0.02em; + } + + p { + margin: 0; + font-size: 0.95rem; + line-height: 1.6; + color: #4b5563; + max-width: 600px; + } + + .stripeApology { + margin-top: 8px !important; + font-size: 0.875rem !important; + font-style: italic; + color: #6b7280 !important; + } +} + +.stripeButton { + align-self: flex-start; + + background: linear-gradient(135deg, #7c3aed 0%, #a855f7 100%) !important; + color: white !important; + font-weight: 600; + border: none !important; + padding: 12px 24px; + transition: all 0.2s ease; + box-shadow: 0 4px 12px rgba(124, 58, 237, 0.25), 0 2px 4px rgba(124, 58, 237, 0.1) !important; + min-width: 180px; + border-radius: 8px !important; + + &:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(124, 58, 237, 0.35), 0 4px 8px rgba(124, 58, 237, 0.15) !important; + background: linear-gradient(135deg, #6d28d9 0%, #9333ea 100%) !important; + } + + &:disabled { + background: linear-gradient(135deg, #c4b5fd 0%, #ddd6fe 100%) !important; + color: #9ca3af !important; + transform: none; + box-shadow: 0 2px 8px rgba(139, 92, 246, 0.1) !important; + opacity: 0.7; + } + + &[data-loading="true"] { + background: linear-gradient(135deg, #a78bfa 0%, #c4b5fd 100%) !important; + color: white !important; + box-shadow: 0 2px 8px rgba(139, 92, 246, 0.2) !important; + } + + @media (max-width: 768px) { + align-self: stretch; + + :global(button) { + width: 100%; + min-width: unset; + } + } +} diff --git a/frontend/src/components/routes/event/EventDashboard/index.tsx b/frontend/src/components/routes/event/EventDashboard/index.tsx index ddeb77a671..8cd14b5f94 100644 --- a/frontend/src/components/routes/event/EventDashboard/index.tsx +++ b/frontend/src/components/routes/event/EventDashboard/index.tsx @@ -13,12 +13,15 @@ import {formatCurrency} from "../../../../utilites/currency.ts"; import {formatDate} from "../../../../utilites/dates.ts"; import {Button, Skeleton} from "@mantine/core"; import {useMediaQuery} from "@mantine/hooks"; -import {IconX} from "@tabler/icons-react"; +import {IconAlertCircle, IconX} from "@tabler/icons-react"; import {useGetAccount} from "../../../../queries/useGetAccount.ts"; import {useUpdateEventStatus} from "../../../../mutations/useUpdateEventStatus.ts"; import {confirmationDialog} from "../../../../utilites/confirmationDialog.tsx"; import {showError, showSuccess} from "../../../../utilites/notifications.tsx"; import {useEffect, useState} from 'react'; +import {StripePlatform} from "../../../../types.ts"; +import {isHiEvents} from "../../../../utilites/helpers.ts"; +import {StripeConnectButton} from "../../../common/StripeConnectButton"; export const DashBoardSkeleton = () => { return ( @@ -44,6 +47,10 @@ export const EventDashboard = () => { const [isChecklistVisible, setIsChecklistVisible] = useState(true); const [isMounted, setIsMounted] = useState(false); + const showStripeUpgradeNotice = account?.stripe_platform === StripePlatform.Canada.valueOf() + && account?.stripe_connect_setup_complete + && isHiEvents(); + useEffect(() => { setIsMounted(true); const dismissed = window.localStorage.getItem('setupChecklistDismissed-' + eventId); @@ -106,6 +113,30 @@ export const EventDashboard = () => { {!event && } + {showStripeUpgradeNotice && ( + +
+
+ +
+
+
+

{t`Important: Stripe reconnection required`}

+

{t`We've relocated our headquarters to Ireland. As a result, we need you to reconnect your Stripe account. This quick process takes just a few minutes. Your sales and existing data remain completely unaffected.`}

+

{t`Sorry for the inconvenience.`}

+
+ +
+
+
+ )} + {event && (<> diff --git a/frontend/src/components/routes/event/Settings/Sections/EmailSettings/GeneralEmailSettings.tsx b/frontend/src/components/routes/event/Settings/Sections/EmailSettings/GeneralEmailSettings.tsx new file mode 100644 index 0000000000..293242a0b6 --- /dev/null +++ b/frontend/src/components/routes/event/Settings/Sections/EmailSettings/GeneralEmailSettings.tsx @@ -0,0 +1,86 @@ +import {t} from "@lingui/macro"; +import {Button, Switch, TextInput} from "@mantine/core"; +import {useForm} from "@mantine/form"; +import {useParams} from "react-router"; +import {useEffect} from "react"; +import {EventSettings} from "../../../../../../types.ts"; +import {Card} from "../../../../../common/Card"; +import {showSuccess} from "../../../../../../utilites/notifications.tsx"; +import {useFormErrorResponseHandler} from "../../../../../../hooks/useFormErrorResponseHandler.tsx"; +import {useUpdateEventSettings} from "../../../../../../mutations/useUpdateEventSettings.ts"; +import {useGetEventSettings} from "../../../../../../queries/useGetEventSettings.ts"; +import {Editor} from "../../../../../common/Editor"; +import {HeadingWithDescription} from "../../../../../common/Card/CardHeading"; + +export const GeneralEmailSettings = () => { + const {eventId} = useParams(); + const eventSettingsQuery = useGetEventSettings(eventId); + const updateMutation = useUpdateEventSettings(); + const form = useForm({ + initialValues: { + support_email: '', + email_footer_message: '', + notify_organizer_of_new_orders: true, + } + }); + const formErrorHandle = useFormErrorResponseHandler(); + + useEffect(() => { + if (eventSettingsQuery?.isFetched && eventSettingsQuery?.data) { + form.setValues({ + support_email: eventSettingsQuery.data.support_email, + email_footer_message: eventSettingsQuery.data.email_footer_message, + }); + } + }, [eventSettingsQuery.isFetched]); + + const handleSubmit = (values: Partial) => { + updateMutation.mutate({ + eventSettings: values, + eventId: eventId, + }, { + onSuccess: () => { + showSuccess(t`Successfully Updated Email Settings`); + }, + onError: (error) => { + formErrorHandle(form, error); + } + }); + } + + return ( + + +
+
+ + + form.setFieldValue('email_footer_message', value)} + /> + +

{t`Notification Settings`}

+ + + +
+
+
+ ); +} diff --git a/frontend/src/components/routes/event/Settings/Sections/EmailSettings/TemplateSettings.tsx b/frontend/src/components/routes/event/Settings/Sections/EmailSettings/TemplateSettings.tsx new file mode 100644 index 0000000000..ada9939b0d --- /dev/null +++ b/frontend/src/components/routes/event/Settings/Sections/EmailSettings/TemplateSettings.tsx @@ -0,0 +1,50 @@ +import {useState} from 'react'; +import {useParams} from 'react-router'; +import {useGetEmailTemplatesForEvent} from '../../../../../../queries/useGetEmailTemplates'; +import {useGetDefaultEmailTemplates} from '../../../../../../queries/useGetDefaultEmailTemplates'; +import { + useCreateEmailTemplateForEvent, +} from '../../../../../../mutations/useCreateEmailTemplate'; +import {usePreviewEmailTemplateForEvent} from '../../../../../../mutations/usePreviewEmailTemplate'; +import {useUpdateEmailTemplateForEvent} from "../../../../../../mutations/useUpdateEmailTemplate.ts"; +import {useDeleteEmailTemplateForEvent} from "../../../../../../mutations/useDeleteEmailTemplate.ts"; +import {EmailTemplateSettingsBase} from '../../../../../common/EmailTemplateSettings'; + +export const TemplateSettings = () => { + const {eventId} = useParams(); + const [shouldFetchDefaults, setShouldFetchDefaults] = useState(false); + + // Queries + const {data: templatesData, isLoading} = useGetEmailTemplatesForEvent(eventId!, {include_inactive: true}); + const {data: defaultTemplatesData} = useGetDefaultEmailTemplates(shouldFetchDefaults); + + // Mutations + const createMutation = useCreateEmailTemplateForEvent(); + const updateMutation = useUpdateEmailTemplateForEvent(); + const deleteMutation = useDeleteEmailTemplateForEvent(); + const previewMutation = usePreviewEmailTemplateForEvent(); + + const templates = templatesData?.data || []; + + const handleCreateTemplate = () => { + // Enable fetching default templates if not already fetched + if (!defaultTemplatesData) { + setShouldFetchDefaults(true); + } + }; + + return ( + + ); +}; diff --git a/frontend/src/components/routes/event/Settings/Sections/EmailSettings/index.tsx b/frontend/src/components/routes/event/Settings/Sections/EmailSettings/index.tsx index 44b51aa596..9462402f05 100644 --- a/frontend/src/components/routes/event/Settings/Sections/EmailSettings/index.tsx +++ b/frontend/src/components/routes/event/Settings/Sections/EmailSettings/index.tsx @@ -1,86 +1,13 @@ -import {t} from "@lingui/macro"; -import {Button, Switch, TextInput} from "@mantine/core"; -import {useForm} from "@mantine/form"; -import {useParams} from "react-router"; -import {useEffect} from "react"; -import {EventSettings} from "../../../../../../types.ts"; -import {Card} from "../../../../../common/Card"; -import {showSuccess} from "../../../../../../utilites/notifications.tsx"; -import {useFormErrorResponseHandler} from "../../../../../../hooks/useFormErrorResponseHandler.tsx"; -import {useUpdateEventSettings} from "../../../../../../mutations/useUpdateEventSettings.ts"; -import {useGetEventSettings} from "../../../../../../queries/useGetEventSettings.ts"; -import {Editor} from "../../../../../common/Editor"; -import {HeadingWithDescription} from "../../../../../common/Card/CardHeading"; +import {GeneralEmailSettings} from './GeneralEmailSettings'; +import {TemplateSettings} from './TemplateSettings'; export const EmailSettings = () => { - const {eventId} = useParams(); - const eventSettingsQuery = useGetEventSettings(eventId); - const updateMutation = useUpdateEventSettings(); - const form = useForm({ - initialValues: { - support_email: '', - email_footer_message: '', - notify_organizer_of_new_orders: true, - } - }); - const formErrorHandle = useFormErrorResponseHandler(); - - useEffect(() => { - if (eventSettingsQuery?.isFetched && eventSettingsQuery?.data) { - form.setValues({ - support_email: eventSettingsQuery.data.support_email, - email_footer_message: eventSettingsQuery.data.email_footer_message, - }); - } - }, [eventSettingsQuery.isFetched]); - - const handleSubmit = (values: Partial) => { - updateMutation.mutate({ - eventSettings: values, - eventId: eventId, - }, { - onSuccess: () => { - showSuccess(t`Successfully Updated Email Settings`); - }, - onError: (error) => { - formErrorHandle(form, error); - } - }); - } - return ( - - -
-
- - - form.setFieldValue('email_footer_message', value)} - /> - -

{t`Notification Settings`}

- - - -
-
-
+
+ + +
); -} +}; + +export default EmailSettings; diff --git a/frontend/src/components/routes/event/Settings/index.tsx b/frontend/src/components/routes/event/Settings/index.tsx index 6f94af5d81..433fe19430 100644 --- a/frontend/src/components/routes/event/Settings/index.tsx +++ b/frontend/src/components/routes/event/Settings/index.tsx @@ -49,7 +49,7 @@ export const Settings = () => { }, { id: 'email-settings', - label: t`Email`, + label: t`Email & Templates`, icon: IconAt, component: EmailSettings }, diff --git a/frontend/src/components/routes/event/TicketDesigner/TicketDesigner.module.scss b/frontend/src/components/routes/event/TicketDesigner/TicketDesigner.module.scss new file mode 100644 index 0000000000..c2f478f973 --- /dev/null +++ b/frontend/src/components/routes/event/TicketDesigner/TicketDesigner.module.scss @@ -0,0 +1,210 @@ +@use "../../../../styles/mixins.scss"; + +.container { + display: flex; + flex-direction: row; + margin: calc(var(--hi-spacing-lg) * -1); + min-height: calc(100vh - 60px); + + h2 { + margin-bottom: 0; + } + + @include mixins.respond-below(lg) { + flex-direction: column; + margin: 0; + min-height: auto; + } +} + +.sidebar { + min-width: 380px; + max-width: 380px; + background-color: #ffffff; + padding: var(--hi-spacing-lg); + height: calc(100vh - 55px); + overflow-y: auto; + position: sticky; + top: 0; + border-right: 1px solid var(--mantine-color-gray-2); + + @include mixins.respond-below(lg) { + width: 100%; + min-width: unset; + max-width: unset; + position: relative; + overflow: auto; + height: auto; + border-right: none; + border-bottom: 1px solid var(--mantine-color-gray-2); + padding: var(--hi-spacing-md); + } +} + +.sticky { + position: sticky; + top: 0; +} + +.header { + margin-bottom: var(--hi-spacing-lg); + padding-bottom: var(--hi-spacing-md); + border-bottom: 1px solid var(--mantine-color-gray-2); + + h2 { + margin: 0 0 var(--hi-spacing-xs) 0; + font-size: 1.375rem; + font-weight: 600; + color: var(--mantine-color-gray-9); + } +} + +.accordion { + margin-bottom: 0; + + .accordionItem { + border: 1px solid var(--mantine-color-gray-3); + border-radius: var(--mantine-radius-md); + overflow: hidden; + + &:not(:last-child) { + margin-bottom: var(--hi-spacing-md); + } + + :global(.mantine-Accordion-control) { + padding: var(--hi-spacing-md); + background: var(--mantine-color-gray-0); + + &:hover { + background: var(--mantine-color-gray-1); + } + + &[data-active] { + border-bottom: 1px solid var(--mantine-color-gray-2); + } + } + + :global(.mantine-Accordion-panel) { + padding: 0; + background: white; + } + + :global(.mantine-Accordion-content) { + padding: var(--hi-spacing-lg); + } + } +} + +.fieldset { + border: none; + padding: 0; + margin: 0; + + // Fix large margins on ColorInput components + :global(.mantine-ColorInput-root) { + margin-bottom: 0; + } + + :global(.mantine-ColorInput-label) { + font-weight: 500; + font-size: 0.875rem; + } + + :global(.mantine-ColorInput-description) { + font-size: 0.8125rem; + margin-top: 0.25rem; + } + + // TextInput styling + :global(.mantine-TextInput-label) { + font-weight: 500; + font-size: 0.875rem; + } + + :global(.mantine-TextInput-description) { + font-size: 0.8125rem; + margin-top: 0.25rem; + } + + &:disabled { + opacity: 0.6; + pointer-events: none; + } +} + +.preview { + height: calc(100vh - 55px); + width: 100%; + overflow: hidden; + min-width: 500px; + display: flex; + flex-direction: column; + background: white; + border-left: 1px solid var(--mantine-color-gray-2); + + @include mixins.respond-below(lg) { + width: 100%; + min-width: unset; + max-width: unset; + position: relative; + overflow: auto; + height: auto; + border-left: none; + border-top: 1px solid var(--mantine-color-gray-2); + padding: 0; + } +} + +.previewHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + background: white; + border-bottom: 1px solid var(--mantine-color-gray-3); + + h3 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + } +} + +.previewContent { + flex: 1; + overflow: auto; + display: flex; + background: var(--mantine-color-gray-0); + min-height: 0; + min-width: 0; + + @include mixins.respond-below(md) { + min-height: 400px; + } + + @include mixins.respond-below(sm) { + min-height: 350px; + } +} + +@media (max-width: 768px) { + .container { + flex-direction: column; + height: auto; + } + + .sidebar { + width: 100%; + overflow-y: visible; + padding-right: 0; + } + + .preview { + min-height: 500px; + padding: 0; + } + + .previewHeader { + padding: 0.75rem 1rem; + } +} \ No newline at end of file diff --git a/frontend/src/components/routes/event/TicketDesigner/TicketDesignerPrint.tsx b/frontend/src/components/routes/event/TicketDesigner/TicketDesignerPrint.tsx new file mode 100644 index 0000000000..410819a542 --- /dev/null +++ b/frontend/src/components/routes/event/TicketDesigner/TicketDesignerPrint.tsx @@ -0,0 +1,101 @@ +import {useParams} from 'react-router'; +import {useGetEvent} from '../../../../queries/useGetEvent.ts'; +import {useGetMe} from '../../../../queries/useGetMe.ts'; +import {useGetEventSettings} from '../../../../queries/useGetEventSettings.ts'; +import {useGetEventImages} from '../../../../queries/useGetEventImages.ts'; +import {AttendeeTicket} from '../../../common/AttendeeTicket'; +import {PoweredByFooter} from '../../../common/PoweredByFooter'; +import {t} from '@lingui/macro'; +import {useEffect} from "react"; +import classes from '../../../routes/product-widget/PrintOrder/PrintOrder.module.scss'; + +const TicketDesignerPrint = () => { + const {eventId} = useParams(); + const eventQuery = useGetEvent(eventId); + const meQuery = useGetMe(); + const settingsQuery = useGetEventSettings(eventId); + const imagesQuery = useGetEventImages(eventId); + + const event = eventQuery.data; + const user = meQuery.data; + const settings = settingsQuery.data; + const images = imagesQuery.data; + + useEffect(() => { + if (event && user && settings) { + setTimeout(() => window?.print(), 500); + } + }, [event, user, settings]); + + if (!event || !user || !settings) { + return null; + } + + const mockProduct = { + id: 1, + title: t`General Admission`, + price: 2500, + type: "TICKET" as const, + sale_start_date: null, + sale_end_date: null, + max_per_order: null, + min_per_order: null, + quantity_available: null, + is_hidden: false, + sort_order: 1, + description: "", + is_hidden_without_promo_code: false + }; + + const mockAttendee = { + id: 1, + public_id: "PREVIEW12345", + short_id: "P1234", + first_name: user.first_name || "John", + last_name: user.last_name || "Doe", + email: user.email || "john.doe@example.com", + status: "ACTIVE" as const, + checked_in_at: null, + product_id: mockProduct.id, + product: mockProduct, + product_price_id: 1, + order_id: 1, + order: { + id: 1, + short_id: "ORD123", + public_id: "ORD123", + created_at: new Date().toISOString(), + total_gross: 2500, + currency: event.currency || "USD", + } + }; + + // Merge the ticket design settings and images into the event + const eventWithDesignSettings = { + ...event, + settings: { + ...event.settings, + ticket_design_settings: settings.ticket_design_settings + }, + images: images || [] + }; + + return ( +
+

{t`Ticket Preview for`} {event.title}

+
+ +
+ +
+
+
+ ); +} + +export default TicketDesignerPrint; diff --git a/frontend/src/components/routes/event/TicketDesigner/TicketPreview.module.scss b/frontend/src/components/routes/event/TicketDesigner/TicketPreview.module.scss new file mode 100644 index 0000000000..f6569ecc87 --- /dev/null +++ b/frontend/src/components/routes/event/TicketDesigner/TicketPreview.module.scss @@ -0,0 +1,44 @@ +@use "../../../../styles/mixins.scss"; + +.previewWrapper { + display: flex; + justify-content: center; + align-items: flex-start; + padding: 2rem; + width: 100%; + height: 100%; + overflow-x: auto; + overflow-y: auto; + + @include mixins.respond-below(lg) { + padding: 1.5rem; + overflow-x: auto; + min-width: 0; + + // Allow horizontal scroll on tablets when ticket is wider than viewport + > * { + flex-shrink: 0; + } + } + + @include mixins.respond-below(md) { + padding: 1rem; + } + + @include mixins.respond-below(sm) { + padding: 0.5rem; + } +} + +.loadingState { + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; + color: #6c757d; + font-style: italic; + + p { + margin: 0; + } +} \ No newline at end of file diff --git a/frontend/src/components/routes/event/TicketDesigner/TicketPreview.tsx b/frontend/src/components/routes/event/TicketDesigner/TicketPreview.tsx new file mode 100644 index 0000000000..7e9e0dd6ab --- /dev/null +++ b/frontend/src/components/routes/event/TicketDesigner/TicketPreview.tsx @@ -0,0 +1,107 @@ +import {useGetEvent} from "../../../../queries/useGetEvent.ts"; +import {useGetMe} from "../../../../queries/useGetMe.ts"; +import {t} from "@lingui/macro"; +import {IdParam} from "../../../../types.ts"; +import {AttendeeTicket} from "../../../common/AttendeeTicket"; +import classes from './TicketPreview.module.scss'; + +interface TicketDesignSettings { + accent_color: string; + logo_image_id: IdParam | null; + footer_text: string | null; + enabled: boolean; +} + +interface TicketPreviewProps { + settings: TicketDesignSettings; + eventId: IdParam; + logoUrl?: string; +} + +export const TicketPreview = ({settings, eventId, logoUrl}: TicketPreviewProps) => { + const eventQuery = useGetEvent(eventId); + const meQuery = useGetMe(); + + const event = eventQuery.data; + const user = meQuery.data; + + if (!event || !user) { + return ( +
+

{t`Loading preview...`}

+
+ ); + } + + const mockProduct = { + id: 1, + title: t`General Admission`, + price: 2500, + type: "TICKET" as const, + sale_start_date: null, + sale_end_date: null, + max_per_order: null, + min_per_order: null, + quantity_available: null, + is_hidden: false, + sort_order: 1, + description: "", + is_hidden_without_promo_code: false + }; + + const mockAttendee = { + id: 1, + public_id: "PREVIEW12345", + short_id: "P1234", + first_name: user.first_name || "John", + last_name: user.last_name || "Doe", + email: user.email || "john.doe@example.com", + status: "ACTIVE" as const, + checked_in_at: null, + product_id: mockProduct.id, + product: mockProduct, + product_price_id: 1, + order_id: 1, + order: { + id: 1, + short_id: "ORD123", + created_at: new Date().toISOString(), + total_gross: 2500, + currency: event.currency || "USD", + } + }; + + const eventWithDesignSettings = { + ...event, + settings: { + ...event.settings, + ticket_design_settings: { + accent_color: settings.accent_color, + logo_image_id: settings.logo_image_id, + footer_text: settings.footer_text, + enabled: settings.enabled + } + }, + images: logoUrl && settings.logo_image_id ? [ + ...((event.images || []).filter(img => img.type !== 'TICKET_LOGO')), + { + id: settings.logo_image_id, + type: 'TICKET_LOGO' as const, + url: logoUrl, + size_bytes: 0, + filename: '' + } + ] : (event.images || []).filter(img => img.type !== 'TICKET_LOGO') + }; + + return ( +
+ +
+ ); +}; diff --git a/frontend/src/components/routes/event/TicketDesigner/index.tsx b/frontend/src/components/routes/event/TicketDesigner/index.tsx new file mode 100644 index 0000000000..a9068c689b --- /dev/null +++ b/frontend/src/components/routes/event/TicketDesigner/index.tsx @@ -0,0 +1,220 @@ +import {useEffect, useState} from "react"; +import classes from './TicketDesigner.module.scss'; +import {useParams} from "react-router"; +import {useGetEventSettings} from "../../../../queries/useGetEventSettings.ts"; +import {useUpdateEventSettings} from "../../../../mutations/useUpdateEventSettings.ts"; +import {useFormErrorResponseHandler} from "../../../../hooks/useFormErrorResponseHandler.tsx"; +import {IdParam} from "../../../../types.ts"; +import {showSuccess} from "../../../../utilites/notifications.tsx"; +import {t} from "@lingui/macro"; +import {useForm} from "@mantine/form"; +import {Button, ColorInput, Textarea, Accordion, Stack, Text, Group} from "@mantine/core"; +import {IconColorSwatch, IconHelp, IconPrinter} from "@tabler/icons-react"; +import {Tooltip} from "../../../common/Tooltip"; +import {ImageUploadDropzone} from "../../../common/ImageUploadDropzone"; +import {queryClient} from "../../../../utilites/queryClient.ts"; +import {GET_EVENT_IMAGES_QUERY_KEY, useGetEventImages} from "../../../../queries/useGetEventImages.ts"; +import {LoadingMask} from "../../../common/LoadingMask"; +import {TicketPreview} from "./TicketPreview"; + +interface TicketDesignSettings { + accent_color: string; + logo_image_id: IdParam; + footer_text: string | null; + enabled: boolean; +} + +const TicketDesigner = () => { + const {eventId} = useParams(); + const eventSettingsQuery = useGetEventSettings(eventId); + const eventImagesQuery = useGetEventImages(eventId); + const updateMutation = useUpdateEventSettings(); + + const [accordionValue, setAccordionValue] = useState(['design']); + + const existingLogo = eventImagesQuery.data?.find((image) => image.type === 'TICKET_LOGO'); + + const form = useForm({ + initialValues: { + accent_color: '#333333', + logo_image_id: undefined, + footer_text: '', + enabled: true, + } + }); + + const formErrorHandle = useFormErrorResponseHandler(); + + useEffect(() => { + if (eventSettingsQuery?.isFetched && eventSettingsQuery?.data?.ticket_design_settings) { + const settings = eventSettingsQuery.data.ticket_design_settings; + form.setValues({ + accent_color: settings.accent_color || '#333333', + logo_image_id: settings.logo_image_id || undefined, + footer_text: settings.footer_text || '', + enabled: settings.enabled !== false, + }); + } + }, [eventSettingsQuery.isFetched]); + + useEffect(() => { + if (existingLogo?.id) { + form.setFieldValue('logo_image_id', existingLogo.id); + } else { + form.setFieldValue('logo_image_id', null); + } + }, [existingLogo?.id]); + + const handleSubmit = (values: TicketDesignSettings) => { + updateMutation.mutate( + { + eventSettings: { + ticket_design_settings: { + accent_color: values.accent_color, + logo_image_id: values.logo_image_id, + footer_text: values.footer_text || undefined, + enabled: values.enabled + } + }, + eventId: eventId + }, + { + onSuccess: () => { + showSuccess(t`Ticket design saved successfully`); + }, + onError: (error) => { + formErrorHandle(form, error); + }, + } + ); + }; + + const handleImageChange = () => { + queryClient.invalidateQueries({ + queryKey: [GET_EVENT_IMAGES_QUERY_KEY, eventId] + }); + }; + + if (eventSettingsQuery.isLoading || eventImagesQuery.isLoading) { + return ; + } + + return ( +
+
+
+
+

{t`Ticket Design`}

+ {t`Customize your ticket appearance`} +
+ +
+
+ + + }> + {t`Design Elements`} + + + +
+ +
+ +
+ + {t`Logo`} + + + + + +
+ +
+