diff --git a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php index 4e3ebde4d..74836e48b 100644 --- a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php @@ -62,6 +62,7 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ final public const ATTENDEE_DETAILS_COLLECTION_METHOD = 'attendee_details_collection_method'; final public const SHOW_MARKETING_OPT_IN = 'show_marketing_opt_in'; final public const HOMEPAGE_THEME_SETTINGS = 'homepage_theme_settings'; + final public const PASS_PLATFORM_FEE_TO_BUYER = 'pass_platform_fee_to_buyer'; protected int $id; protected int $event_id; @@ -115,6 +116,7 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ protected string $attendee_details_collection_method = 'PER_TICKET'; protected bool $show_marketing_opt_in = true; protected array|string|null $homepage_theme_settings = null; + protected bool $pass_platform_fee_to_buyer = false; public function toArray(): array { @@ -171,6 +173,7 @@ public function toArray(): array 'attendee_details_collection_method' => $this->attendee_details_collection_method ?? null, 'show_marketing_opt_in' => $this->show_marketing_opt_in ?? null, 'homepage_theme_settings' => $this->homepage_theme_settings ?? null, + 'pass_platform_fee_to_buyer' => $this->pass_platform_fee_to_buyer ?? null, ]; } @@ -746,4 +749,15 @@ public function getHomepageThemeSettings(): array|string|null { return $this->homepage_theme_settings; } + + public function setPassPlatformFeeToBuyer(bool $pass_platform_fee_to_buyer): self + { + $this->pass_platform_fee_to_buyer = $pass_platform_fee_to_buyer; + return $this; + } + + public function getPassPlatformFeeToBuyer(): bool + { + return $this->pass_platform_fee_to_buyer; + } } diff --git a/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php index 076da8954..ecb073ac0 100644 --- a/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php @@ -25,6 +25,8 @@ abstract class OrderItemDomainObjectAbstract extends \HiEvents\DomainObjects\Abs final public const TOTAL_SERVICE_FEE = 'total_service_fee'; final public const TAXES_AND_FEES_ROLLUP = 'taxes_and_fees_rollup'; final public const PRODUCT_TYPE = 'product_type'; + final public const BUNDLE_GROUP_ID = 'bundle_group_id'; + final public const IS_BUNDLE_PRIMARY = 'is_bundle_primary'; protected int $id; protected int $order_id; @@ -41,6 +43,8 @@ abstract class OrderItemDomainObjectAbstract extends \HiEvents\DomainObjects\Abs protected ?float $total_service_fee = 0.0; protected array|string|null $taxes_and_fees_rollup = null; protected string $product_type = 'TICKET'; + protected ?string $bundle_group_id = null; + protected bool $is_bundle_primary = false; public function toArray(): array { @@ -60,6 +64,8 @@ public function toArray(): array 'total_service_fee' => $this->total_service_fee ?? null, 'taxes_and_fees_rollup' => $this->taxes_and_fees_rollup ?? null, 'product_type' => $this->product_type ?? null, + 'bundle_group_id' => $this->bundle_group_id ?? null, + 'is_bundle_primary' => $this->is_bundle_primary ?? null, ]; } @@ -227,4 +233,26 @@ public function getProductType(): string { return $this->product_type; } + + public function setBundleGroupId(?string $bundle_group_id): self + { + $this->bundle_group_id = $bundle_group_id; + return $this; + } + + public function getBundleGroupId(): ?string + { + return $this->bundle_group_id; + } + + public function setIsBundlePrimary(bool $is_bundle_primary): self + { + $this->is_bundle_primary = $is_bundle_primary; + return $this; + } + + public function getIsBundlePrimary(): bool + { + return $this->is_bundle_primary; + } } diff --git a/backend/app/DomainObjects/Generated/OrganizerSettingDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrganizerSettingDomainObjectAbstract.php index d7e55a029..fa2de3ceb 100644 --- a/backend/app/DomainObjects/Generated/OrganizerSettingDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/OrganizerSettingDomainObjectAbstract.php @@ -27,6 +27,7 @@ abstract class OrganizerSettingDomainObjectAbstract extends \HiEvents\DomainObje final public const LOCATION_DETAILS = 'location_details'; final public const DEFAULT_ATTENDEE_DETAILS_COLLECTION_METHOD = 'default_attendee_details_collection_method'; final public const DEFAULT_SHOW_MARKETING_OPT_IN = 'default_show_marketing_opt_in'; + final public const DEFAULT_PASS_PLATFORM_FEE_TO_BUYER = 'default_pass_platform_fee_to_buyer'; protected int $id; protected int $organizer_id; @@ -45,6 +46,7 @@ abstract class OrganizerSettingDomainObjectAbstract extends \HiEvents\DomainObje protected array|string|null $location_details = null; protected string $default_attendee_details_collection_method = 'PER_TICKET'; protected bool $default_show_marketing_opt_in = true; + protected bool $default_pass_platform_fee_to_buyer = false; public function toArray(): array { @@ -66,6 +68,7 @@ public function toArray(): array 'location_details' => $this->location_details ?? null, 'default_attendee_details_collection_method' => $this->default_attendee_details_collection_method ?? null, 'default_show_marketing_opt_in' => $this->default_show_marketing_opt_in ?? null, + 'default_pass_platform_fee_to_buyer' => $this->default_pass_platform_fee_to_buyer ?? null, ]; } @@ -256,4 +259,15 @@ public function getDefaultShowMarketingOptIn(): bool { return $this->default_show_marketing_opt_in; } + + public function setDefaultPassPlatformFeeToBuyer(bool $default_pass_platform_fee_to_buyer): self + { + $this->default_pass_platform_fee_to_buyer = $default_pass_platform_fee_to_buyer; + return $this; + } + + public function getDefaultPassPlatformFeeToBuyer(): bool + { + return $this->default_pass_platform_fee_to_buyer; + } } diff --git a/backend/app/DomainObjects/Generated/ProductPriceTaxAndFeesDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/ProductPriceTaxAndFeesDomainObjectAbstract.php new file mode 100644 index 000000000..0000f0cef --- /dev/null +++ b/backend/app/DomainObjects/Generated/ProductPriceTaxAndFeesDomainObjectAbstract.php @@ -0,0 +1,104 @@ + $this->id ?? null, + 'product_price_id' => $this->product_price_id ?? null, + 'tax_and_fee_id' => $this->tax_and_fee_id ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + 'deleted_at' => $this->deleted_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setProductPriceId(int $product_price_id): self + { + $this->product_price_id = $product_price_id; + return $this; + } + + public function getProductPriceId(): int + { + return $this->product_price_id; + } + + public function setTaxAndFeeId(int $tax_and_fee_id): self + { + $this->tax_and_fee_id = $tax_and_fee_id; + return $this; + } + + public function getTaxAndFeeId(): int + { + return $this->tax_and_fee_id; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } +} diff --git a/backend/app/DomainObjects/ProductPriceTaxAndFeesDomainObject.php b/backend/app/DomainObjects/ProductPriceTaxAndFeesDomainObject.php new file mode 100644 index 000000000..537da30f9 --- /dev/null +++ b/backend/app/DomainObjects/ProductPriceTaxAndFeesDomainObject.php @@ -0,0 +1,7 @@ + ['boolean'], + // Platform fee settings + 'pass_platform_fee_to_buyer' => ['boolean'], + // Homepage theme settings 'homepage_theme_settings' => ['nullable', 'array'], 'homepage_theme_settings.accent' => ['nullable', 'string', ...RulesHelper::HEX_COLOR], diff --git a/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php b/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php index 0562881e1..6108bab50 100644 --- a/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php +++ b/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php @@ -17,6 +17,7 @@ public static function rules(): array // Event defaults 'default_attendee_details_collection_method' => ['sometimes', 'nullable', Rule::in(AttendeeDetailsCollectionMethod::valuesArray())], 'default_show_marketing_opt_in' => ['sometimes', 'nullable', 'boolean'], + 'default_pass_platform_fee_to_buyer' => ['sometimes', 'nullable', 'boolean'], // Social handles 'facebook_handle' => ['sometimes', 'nullable', 'string', 'max:255'], diff --git a/backend/app/Resources/Event/EventSettingsResource.php b/backend/app/Resources/Event/EventSettingsResource.php index 9653579ac..81383392d 100644 --- a/backend/app/Resources/Event/EventSettingsResource.php +++ b/backend/app/Resources/Event/EventSettingsResource.php @@ -71,6 +71,9 @@ public function toArray($request): array // Marketing settings 'show_marketing_opt_in' => $this->getShowMarketingOptIn(), + // Platform fee settings + 'pass_platform_fee_to_buyer' => $this->getPassPlatformFeeToBuyer(), + // Homepage theme settings 'homepage_theme_settings' => $this->getHomepageThemeSettings(), ]; diff --git a/backend/app/Resources/Event/EventSettingsResourcePublic.php b/backend/app/Resources/Event/EventSettingsResourcePublic.php index 46cfac1f4..2cf8a1b4b 100644 --- a/backend/app/Resources/Event/EventSettingsResourcePublic.php +++ b/backend/app/Resources/Event/EventSettingsResourcePublic.php @@ -77,6 +77,9 @@ public function toArray($request): array // Marketing settings 'show_marketing_opt_in' => $this->getShowMarketingOptIn(), + // Platform fee settings + 'pass_platform_fee_to_buyer' => $this->getPassPlatformFeeToBuyer(), + // Homepage theme settings 'homepage_theme_settings' => $this->getHomepageThemeSettings(), ]; diff --git a/backend/app/Resources/Organizer/OrganizerSettingsResource.php b/backend/app/Resources/Organizer/OrganizerSettingsResource.php index ead73151c..dfa2f3692 100644 --- a/backend/app/Resources/Organizer/OrganizerSettingsResource.php +++ b/backend/app/Resources/Organizer/OrganizerSettingsResource.php @@ -17,6 +17,7 @@ public function toArray($request): array 'organizer_id' => $this->getOrganizerId(), 'default_attendee_details_collection_method' => $this->getDefaultAttendeeDetailsCollectionMethod(), 'default_show_marketing_opt_in' => $this->getDefaultShowMarketingOptIn(), + 'default_pass_platform_fee_to_buyer' => $this->getDefaultPassPlatformFeeToBuyer(), 'social_media_handles' => $this->getSocialMediaHandles(), 'homepage_theme_settings' => $this->getHomepageThemeSettings(), 'homepage_visibility' => $this->getHomepageVisibility(), diff --git a/backend/app/Resources/Product/ProductResourcePublic.php b/backend/app/Resources/Product/ProductResourcePublic.php index 103b94d90..28a775f5b 100644 --- a/backend/app/Resources/Product/ProductResourcePublic.php +++ b/backend/app/Resources/Product/ProductResourcePublic.php @@ -41,6 +41,7 @@ public function toArray(Request $request): array ProductPriceResourcePublic::SHOW_QUANTITY_AVAILABLE => $this->getShowQuantityRemaining(), ]), ), + // todo: this should be taxes_and_fees 'taxes' => $this->when( (bool)$this->getTaxAndFees(), fn() => TaxAndFeeResource::collection($this->getTaxAndFees()) diff --git a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php index 92afa69b5..37ee2472b 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php +++ b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php @@ -75,6 +75,9 @@ public function __construct( // Marketing settings public readonly bool $show_marketing_opt_in = true, + // Platform fee settings + public readonly bool $pass_platform_fee_to_buyer = false, + // Homepage theme settings public readonly ?array $homepage_theme_settings = null, ) @@ -146,6 +149,9 @@ public static function createWithDefaults( // Marketing defaults show_marketing_opt_in: true, + // Platform fee defaults + pass_platform_fee_to_buyer: false, + // Homepage theme defaults (simplified 2-color + mode system) homepage_theme_settings: [ 'accent' => '#8b5cf6', diff --git a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php index c5e562faf..23493cfee 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php @@ -127,6 +127,9 @@ public function handle(PartialUpdateEventSettingsDTO $eventSettingsDTO): EventSe // Marketing settings 'show_marketing_opt_in' => $eventSettingsDTO->settings['show_marketing_opt_in'] ?? $existingSettings->getShowMarketingOptIn(), + // Platform fee settings + 'pass_platform_fee_to_buyer' => $eventSettingsDTO->settings['pass_platform_fee_to_buyer'] ?? $existingSettings->getPassPlatformFeeToBuyer(), + // Homepage theme settings 'homepage_theme_settings' => array_key_exists('homepage_theme_settings', $eventSettingsDTO->settings) ? $eventSettingsDTO->settings['homepage_theme_settings'] diff --git a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php index 1a4d6d264..1ee8e33a3 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php @@ -86,6 +86,9 @@ public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObje // Marketing settings 'show_marketing_opt_in' => $settings->show_marketing_opt_in, + // Platform fee settings + 'pass_platform_fee_to_buyer' => $settings->pass_platform_fee_to_buyer, + // Homepage theme settings 'homepage_theme_settings' => $settings->homepage_theme_settings, ], diff --git a/backend/app/Services/Application/Handlers/Organizer/DTO/PartialUpdateOrganizerSettingsDTO.php b/backend/app/Services/Application/Handlers/Organizer/DTO/PartialUpdateOrganizerSettingsDTO.php index 4a87678c3..cb03edffe 100644 --- a/backend/app/Services/Application/Handlers/Organizer/DTO/PartialUpdateOrganizerSettingsDTO.php +++ b/backend/app/Services/Application/Handlers/Organizer/DTO/PartialUpdateOrganizerSettingsDTO.php @@ -23,6 +23,7 @@ public function __construct( #[WithCast(EnumCast::class, AttendeeDetailsCollectionMethod::class)] public readonly AttendeeDetailsCollectionMethod|Optional|null $defaultAttendeeDetailsCollectionMethod, public readonly bool|Optional|null $defaultShowMarketingOptIn, + public readonly bool|Optional|null $defaultPassPlatformFeeToBuyer, // Social public readonly string|Optional|null $facebookHandle, diff --git a/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php b/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php index 6e715f976..cfc25a65c 100644 --- a/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php @@ -52,6 +52,11 @@ public function handle(PartialUpdateOrganizerSettingsDTO $dto): OrganizerSetting $organizerSettings->getDefaultShowMarketingOptIn() ), + 'default_pass_platform_fee_to_buyer' => $dto->getProvided( + 'defaultPassPlatformFeeToBuyer', + $organizerSettings->getDefaultPassPlatformFeeToBuyer() + ), + 'social_media_handles' => array_filter([ 'facebook' => $dto->getProvided('facebookHandle', $organizerSettings->getSocialMediaHandle('facebook')), 'instagram' => $dto->getProvided('instagramHandle', $organizerSettings->getSocialMediaHandle('instagram')), diff --git a/backend/app/Services/Domain/Event/CreateEventService.php b/backend/app/Services/Domain/Event/CreateEventService.php index f6acdf456..94ea070c7 100644 --- a/backend/app/Services/Domain/Event/CreateEventService.php +++ b/backend/app/Services/Domain/Event/CreateEventService.php @@ -222,6 +222,7 @@ private function createEventSettings( 'attendee_details_collection_method' => $organizerSettings->getDefaultAttendeeDetailsCollectionMethod(), 'show_marketing_opt_in' => $organizerSettings->getDefaultShowMarketingOptIn(), + 'pass_platform_fee_to_buyer' => $organizerSettings->getDefaultPassPlatformFeeToBuyer(), 'ticket_design_settings' => [ 'accent_color' => $homepageThemeSettings['accent'] ?? '#333', ], diff --git a/backend/app/Services/Domain/Order/OrderManagementService.php b/backend/app/Services/Domain/Order/OrderManagementService.php index 0a54c553c..fd50e521c 100644 --- a/backend/app/Services/Domain/Order/OrderManagementService.php +++ b/backend/app/Services/Domain/Order/OrderManagementService.php @@ -3,14 +3,20 @@ namespace HiEvents\Services\Domain\Order; use Carbon\Carbon; +use HiEvents\DomainObjects\AccountConfigurationDomainObject; use HiEvents\DomainObjects\AffiliateDomainObject; +use HiEvents\DomainObjects\Enums\TaxCalculationType; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\PromoCodeDomainObject; use HiEvents\DomainObjects\Status\OrderStatus; use HiEvents\Helper\IdHelper; +use HiEvents\Repository\Eloquent\Value\Relationship; +use HiEvents\Repository\Interfaces\AccountRepositoryInterface; +use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Services\Domain\Tax\TaxAndFeeOrderRollupService; use Illuminate\Support\Collection; @@ -18,8 +24,11 @@ class OrderManagementService { public function __construct( - readonly private OrderRepositoryInterface $orderRepository, - readonly private TaxAndFeeOrderRollupService $taxAndFeeOrderRollupService, + readonly private OrderRepositoryInterface $orderRepository, + readonly private TaxAndFeeOrderRollupService $taxAndFeeOrderRollupService, + readonly private OrderPlatformFeePassThroughService $platformFeeService, + readonly private AccountRepositoryInterface $accountRepository, + readonly private EventRepositoryInterface $eventRepository, ) { } @@ -79,16 +88,67 @@ public function updateOrderTotals(OrderDomainObject $order, Collection $orderIte $totalGross += $item->getTotalGross(); } + $rollup = $this->taxAndFeeOrderRollupService->rollup($orderItems); + + $platformFee = $order->getIsManuallyCreated() + ? 0.0 + : $this->calculatePlatformFee($order->getEventId(), $orderItems, $order->getCurrency()); + + if ($platformFee > 0) { + $rollup['fees'][] = [ + 'name' => OrderPlatformFeePassThroughService::getPlatformFeeName(), + 'rate' => $platformFee, + 'type' => TaxCalculationType::FIXED->name, + 'value' => $platformFee, + ]; + $totalFee += $platformFee; + $totalGross += $platformFee; + } + $this->orderRepository->updateFromArray($order->getId(), [ 'total_before_additions' => $totalBeforeAdditions, 'total_tax' => $totalTax, 'total_fee' => $totalFee, 'total_gross' => $totalGross, - 'taxes_and_fees_rollup' => $this->taxAndFeeOrderRollupService->rollup($orderItems), + 'taxes_and_fees_rollup' => $rollup, ]); return $this->orderRepository ->loadRelation(OrderItemDomainObject::class) ->findById($order->getId()); } + + /** + * @param Collection $orderItems + */ + private function calculatePlatformFee(int $eventId, Collection $orderItems, string $currency): float + { + $account = $this->accountRepository + ->loadRelation(new Relationship( + domainObject: AccountConfigurationDomainObject::class, + name: 'configuration', + )) + ->findByEventId($eventId); + + $accountConfiguration = $account->getConfiguration(); + if ($accountConfiguration === null) { + return 0.0; + } + + $event = $this->eventRepository + ->loadRelation(EventSettingDomainObject::class) + ->findById($eventId); + + $eventSettings = $event->getEventSettings(); + if ($eventSettings === null) { + return 0.0; + } + + return $this->platformFeeService->calculateForOrder( + accountConfiguration: $accountConfiguration, + eventSettings: $eventSettings, + orderItems: $orderItems, + currency: $currency, + ); + } } diff --git a/backend/app/Services/Domain/Order/OrderPlatformFeePassThroughService.php b/backend/app/Services/Domain/Order/OrderPlatformFeePassThroughService.php new file mode 100644 index 000000000..558028f29 --- /dev/null +++ b/backend/app/Services/Domain/Order/OrderPlatformFeePassThroughService.php @@ -0,0 +1,101 @@ +config->get('app.saas_mode_enabled')) { + return false; + } + + return $eventSettings->getPassPlatformFeeToBuyer(); + } + + /** + * Calculate the platform fee for a single product price (quantity = 1). + */ + public function calculateForProductPrice( + AccountConfigurationDomainObject $accountConfiguration, + EventSettingDomainObject $eventSettings, + float $priceWithFeesAndTaxes, + string $currency, + ): float + { + if (!$this->isEnabled($eventSettings) || $priceWithFeesAndTaxes <= 0) { + return 0.0; + } + + $order = (new OrderDomainObject()) + ->setCurrency($currency) + ->setTotalGross($priceWithFeesAndTaxes); + + $orderItem = (new OrderItemDomainObject()) + ->setPrice($priceWithFeesAndTaxes) + ->setQuantity(1) + ->setTotalGross($priceWithFeesAndTaxes); + + $order->setOrderItems(collect([$orderItem])); + + $result = $this->applicationFeeCalculationService->calculateApplicationFee( + $accountConfiguration, + $order, + ); + + return $result ? Currency::round($result->netApplicationFee->toFloat()) : 0.0; + } + + /** + * Calculate the platform fee for an order. + * + * @param Collection $orderItems + */ + public function calculateForOrder( + AccountConfigurationDomainObject $accountConfiguration, + EventSettingDomainObject $eventSettings, + Collection $orderItems, + string $currency, + ): float + { + if (!$this->isEnabled($eventSettings)) { + return 0.0; + } + + $totalGross = $orderItems->sum(fn(OrderItemDomainObject $item) => $item->getTotalGross()); + + $order = (new OrderDomainObject()) + ->setCurrency($currency) + ->setTotalGross($totalGross) + ->setOrderItems($orderItems); + + $result = $this->applicationFeeCalculationService->calculateApplicationFee( + $accountConfiguration, + $order, + ); + + return $result ? Currency::round($result->netApplicationFee->toFloat()) : 0.0; + } +} diff --git a/backend/app/Services/Domain/Product/ProductFilterService.php b/backend/app/Services/Domain/Product/ProductFilterService.php index f8dcd8f1b..92445d146 100644 --- a/backend/app/Services/Domain/Product/ProductFilterService.php +++ b/backend/app/Services/Domain/Product/ProductFilterService.php @@ -3,22 +3,36 @@ namespace HiEvents\Services\Domain\Product; use HiEvents\Constants; +use HiEvents\DomainObjects\AccountConfigurationDomainObject; use HiEvents\DomainObjects\CapacityAssignmentDomainObject; +use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\ProductCategoryDomainObject; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\DomainObjects\PromoCodeDomainObject; +use HiEvents\DomainObjects\TaxAndFeesDomainObject; use HiEvents\Helper\Currency; +use HiEvents\Repository\Eloquent\Value\Relationship; +use HiEvents\Repository\Interfaces\AccountRepositoryInterface; +use HiEvents\Repository\Interfaces\EventRepositoryInterface; +use HiEvents\Services\Domain\Order\OrderPlatformFeePassThroughService; use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesDTO; use HiEvents\Services\Domain\Tax\TaxAndFeeCalculationService; use Illuminate\Support\Collection; class ProductFilterService { + private ?AccountConfigurationDomainObject $accountConfiguration = null; + private ?EventSettingDomainObject $eventSettings = null; + private ?string $eventCurrency = null; + public function __construct( private readonly TaxAndFeeCalculationService $taxCalculationService, private readonly ProductPriceService $productPriceService, private readonly AvailableProductQuantitiesFetchService $fetchAvailableProductQuantitiesService, + private readonly OrderPlatformFeePassThroughService $platformFeeService, + private readonly AccountRepositoryInterface $accountRepository, + private readonly EventRepositoryInterface $eventRepository, ) { } @@ -47,9 +61,12 @@ public function filter( ->reject(fn(ProductCategoryDomainObject $category) => $category->getIsHidden()); } + $eventId = $products->first()->getEventId(); + $this->loadAccountConfiguration($eventId); + $productQuantities = $this ->fetchAvailableProductQuantitiesService - ->getAvailableProductQuantities($products->first()->getEventId()); + ->getAvailableProductQuantities($eventId); $filteredProducts = $products ->map(fn(ProductDomainObject $product) => $this->processProduct($product, $productQuantities->productQuantities, $promoCode)) @@ -65,6 +82,25 @@ public function filter( )); } + private function loadAccountConfiguration(int $eventId): void + { + $account = $this->accountRepository + ->loadRelation(new Relationship( + domainObject: AccountConfigurationDomainObject::class, + name: 'configuration', + )) + ->findByEventId($eventId); + + $this->accountConfiguration = $account->getConfiguration(); + $this->eventCurrency = $account->getCurrencyCode(); + + $event = $this->eventRepository + ->loadRelation(EventSettingDomainObject::class) + ->findById($eventId); + + $this->eventSettings = $event->getEventSettings(); + } + private function isHiddenByPromoCode(ProductDomainObject $product, ?PromoCodeDomainObject $promoCode): bool { return $product->getIsHiddenWithoutPromoCode() && !( @@ -167,14 +203,61 @@ private function processProductPrice(ProductDomainObject $product, ProductPriceD $taxAndFees = $this->taxCalculationService ->calculateTaxAndFeesForProductPrice($product, $price); + $feeTotal = $taxAndFees->feeTotal; + $taxTotal = $taxAndFees->taxTotal; + + $platformFee = $this->calculatePlatformFeeForPrice($price->getPrice(), $feeTotal, $taxTotal); + $feeTotal += $platformFee; + + if ($platformFee > 0) { + $this->addPlatformFeeToProduct($product); + } + $price - ->setTaxTotal(Currency::round($taxAndFees->taxTotal)) - ->setFeeTotal(Currency::round($taxAndFees->feeTotal)); + ->setTaxTotal(Currency::round($taxTotal)) + ->setFeeTotal(Currency::round($feeTotal)); } $price->setIsAvailable($this->getPriceAvailability($price, $product)); } + private function calculatePlatformFeeForPrice(float $basePrice, float $feeTotal, float $taxTotal): float + { + if ($this->accountConfiguration === null || $this->eventSettings === null) { + return 0.0; + } + + $priceWithFeesAndTaxes = $basePrice + $feeTotal + $taxTotal; + + return $this->platformFeeService->calculateForProductPrice( + $this->accountConfiguration, + $this->eventSettings, + $priceWithFeesAndTaxes, + $this->eventCurrency, + ); + } + + private function addPlatformFeeToProduct(ProductDomainObject $product): void + { + $existingTaxesAndFees = $product->getTaxAndFees() ?? collect(); + + $hasPlatformFee = $existingTaxesAndFees->contains( + fn(TaxAndFeesDomainObject $fee) => $fee->getId() === OrderPlatformFeePassThroughService::PLATFORM_FEE_ID + ); + + if (!$hasPlatformFee) { + $platformFeeDomainObject = (new TaxAndFeesDomainObject()) + ->setId(OrderPlatformFeePassThroughService::PLATFORM_FEE_ID) + ->setAccountId(0) + ->setName(OrderPlatformFeePassThroughService::getPlatformFeeName()) + ->setType('FEE') + ->setCalculationType('FIXED') + ->setRate(0); + + $product->setTaxAndFees($existingTaxesAndFees->push($platformFeeDomainObject)); + } + } + private function filterProductPrice( ProductDomainObject $product, ProductPriceDomainObject $price, diff --git a/backend/database/migrations/2025_12_11_210341_add_pass_platform_fee_to_buyer_to_settings_tables.php b/backend/database/migrations/2025_12_11_210341_add_pass_platform_fee_to_buyer_to_settings_tables.php new file mode 100644 index 000000000..d5f2bcc0f --- /dev/null +++ b/backend/database/migrations/2025_12_11_210341_add_pass_platform_fee_to_buyer_to_settings_tables.php @@ -0,0 +1,30 @@ +boolean('default_pass_platform_fee_to_buyer')->default(false)->after('default_show_marketing_opt_in'); + }); + + Schema::table('event_settings', function (Blueprint $table) { + $table->boolean('pass_platform_fee_to_buyer')->default(false)->after('show_marketing_opt_in'); + }); + } + + public function down(): void + { + Schema::table('organizer_settings', function (Blueprint $table) { + $table->dropColumn('default_pass_platform_fee_to_buyer'); + }); + + Schema::table('event_settings', function (Blueprint $table) { + $table->dropColumn('pass_platform_fee_to_buyer'); + }); + } +}; diff --git a/backend/tests/Unit/Services/Domain/Order/OrderPlatformFeePassThroughServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderPlatformFeePassThroughServiceTest.php new file mode 100644 index 000000000..2cfdfe662 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Order/OrderPlatformFeePassThroughServiceTest.php @@ -0,0 +1,179 @@ +config = $this->createMock(Repository::class); + $this->applicationFeeCalculationService = $this->createMock(OrderApplicationFeeCalculationService::class); + $this->service = new OrderPlatformFeePassThroughService( + $this->config, + $this->applicationFeeCalculationService, + ); + } + + private function createOrderItem(float $price, int $quantity, float $totalGross): OrderItemDomainObject + { + $item = $this->createMock(OrderItemDomainObject::class); + $item->method('getPrice')->willReturn($price); + $item->method('getQuantity')->willReturn($quantity); + $item->method('getTotalGross')->willReturn($totalGross); + return $item; + } + + private function createAccountConfig(): AccountConfigurationDomainObject + { + return $this->createMock(AccountConfigurationDomainObject::class); + } + + private function createEventSettings(bool $passPlatformFeeToBuyer = true): EventSettingDomainObject + { + $settings = $this->createMock(EventSettingDomainObject::class); + $settings->method('getPassPlatformFeeToBuyer')->willReturn($passPlatformFeeToBuyer); + return $settings; + } + + private function createApplicationFeeDTO(float $amount, string $currency = 'USD'): ApplicationFeeValuesDTO + { + return new ApplicationFeeValuesDTO( + grossApplicationFee: MoneyValue::fromFloat($amount, $currency), + netApplicationFee: MoneyValue::fromFloat($amount, $currency), + ); + } + + public function testIsEnabledReturnsFalseWhenSaasModeDisabled(): void + { + $this->config->method('get')->with('app.saas_mode_enabled')->willReturn(false); + + $eventSettings = $this->createEventSettings(true); + + $this->assertFalse($this->service->isEnabled($eventSettings)); + } + + public function testIsEnabledReturnsFalseWhenEventSettingDisabled(): void + { + $this->config->method('get')->with('app.saas_mode_enabled')->willReturn(true); + + $eventSettings = $this->createEventSettings(false); + + $this->assertFalse($this->service->isEnabled($eventSettings)); + } + + public function testIsEnabledReturnsTrueWhenBothEnabled(): void + { + $this->config->method('get')->with('app.saas_mode_enabled')->willReturn(true); + + $eventSettings = $this->createEventSettings(true); + + $this->assertTrue($this->service->isEnabled($eventSettings)); + } + + public function testCalculateForProductPriceReturnsZeroWhenDisabled(): void + { + $this->config->method('get')->with('app.saas_mode_enabled')->willReturn(true); + + $account = $this->createAccountConfig(); + $eventSettings = $this->createEventSettings(false); + + $fee = $this->service->calculateForProductPrice($account, $eventSettings, 100.00, 'USD'); + + $this->assertEquals(0.0, $fee); + } + + public function testCalculateForProductPriceReturnsZeroForZeroPrice(): void + { + $this->config->method('get')->willReturn(true); + + $account = $this->createAccountConfig(); + $eventSettings = $this->createEventSettings(true); + + $fee = $this->service->calculateForProductPrice($account, $eventSettings, 0.0, 'USD'); + + $this->assertEquals(0.0, $fee); + } + + public function testCalculateForProductPriceDelegatesToApplicationFeeService(): void + { + $this->config->method('get')->willReturn(true); + + $account = $this->createAccountConfig(); + $eventSettings = $this->createEventSettings(true); + + $this->applicationFeeCalculationService + ->expects($this->once()) + ->method('calculateApplicationFee') + ->willReturn($this->createApplicationFeeDTO(5.50)); + + $fee = $this->service->calculateForProductPrice($account, $eventSettings, 100.00, 'USD'); + + $this->assertEquals(5.50, $fee); + } + + public function testCalculateForOrderReturnsZeroWhenDisabled(): void + { + $this->config->method('get')->with('app.saas_mode_enabled')->willReturn(true); + + $orderItems = collect([$this->createOrderItem(10, 2, 20)]); + $account = $this->createAccountConfig(); + $eventSettings = $this->createEventSettings(false); + + $fee = $this->service->calculateForOrder($account, $eventSettings, $orderItems, 'USD'); + + $this->assertEquals(0.0, $fee); + } + + public function testCalculateForOrderDelegatesToApplicationFeeService(): void + { + $this->config->method('get')->willReturn(true); + + $orderItems = collect([ + $this->createOrderItem(10, 1, 10), + $this->createOrderItem(20, 2, 40), + ]); + + $account = $this->createAccountConfig(); + $eventSettings = $this->createEventSettings(true); + + $this->applicationFeeCalculationService + ->expects($this->once()) + ->method('calculateApplicationFee') + ->willReturn($this->createApplicationFeeDTO(6.50)); + + $fee = $this->service->calculateForOrder($account, $eventSettings, $orderItems, 'USD'); + + $this->assertEquals(6.50, $fee); + } + + public function testCalculateForOrderReturnsZeroWhenApplicationFeeServiceReturnsNull(): void + { + $this->config->method('get')->willReturn(true); + + $orderItems = collect([$this->createOrderItem(10, 1, 10)]); + $account = $this->createAccountConfig(); + $eventSettings = $this->createEventSettings(true); + + $this->applicationFeeCalculationService + ->method('calculateApplicationFee') + ->willReturn(null); + + $fee = $this->service->calculateForOrder($account, $eventSettings, $orderItems, 'USD'); + + $this->assertEquals(0.0, $fee); + } +} diff --git a/frontend/src/components/routes/event/Settings/Sections/HomepageAndCheckoutSettings/index.tsx b/frontend/src/components/routes/event/Settings/Sections/HomepageAndCheckoutSettings/index.tsx index 76fba0c3c..640cb202e 100644 --- a/frontend/src/components/routes/event/Settings/Sections/HomepageAndCheckoutSettings/index.tsx +++ b/frontend/src/components/routes/event/Settings/Sections/HomepageAndCheckoutSettings/index.tsx @@ -26,6 +26,7 @@ export const HomepageAndCheckoutSettings = () => { order_timeout_in_minutes: 15, attendee_details_collection_method: 'PER_TICKET' as 'PER_TICKET' | 'PER_ORDER', show_marketing_opt_in: true, + pass_platform_fee_to_buyer: false, }, transformValues: (values) => ({ ...values, @@ -58,6 +59,7 @@ export const HomepageAndCheckoutSettings = () => { order_timeout_in_minutes: eventSettingsQuery.data.order_timeout_in_minutes, attendee_details_collection_method: eventSettingsQuery.data.attendee_details_collection_method || 'PER_TICKET', show_marketing_opt_in: eventSettingsQuery.data.show_marketing_opt_in ?? true, + pass_platform_fee_to_buyer: eventSettingsQuery.data.pass_platform_fee_to_buyer ?? false, }); } }, [eventSettingsQuery.isFetched]); @@ -128,6 +130,13 @@ export const HomepageAndCheckoutSettings = () => { {...form.getInputProps('show_marketing_opt_in', {type: 'checkbox'})} /> + + diff --git a/frontend/src/components/routes/organizer/Settings/Sections/EventDefaults/index.tsx b/frontend/src/components/routes/organizer/Settings/Sections/EventDefaults/index.tsx index 2c346abaa..b0a23fd7d 100644 --- a/frontend/src/components/routes/organizer/Settings/Sections/EventDefaults/index.tsx +++ b/frontend/src/components/routes/organizer/Settings/Sections/EventDefaults/index.tsx @@ -21,6 +21,7 @@ export const EventDefaults = () => { initialValues: { default_attendee_details_collection_method: 'PER_TICKET' as 'PER_TICKET' | 'PER_ORDER', default_show_marketing_opt_in: true, + default_pass_platform_fee_to_buyer: false, } }); @@ -46,11 +47,12 @@ export const EventDefaults = () => { form.setValues({ default_attendee_details_collection_method: organizerSettingsQuery.data.default_attendee_details_collection_method || 'PER_TICKET', default_show_marketing_opt_in: organizerSettingsQuery.data.default_show_marketing_opt_in ?? true, + default_pass_platform_fee_to_buyer: organizerSettingsQuery.data.default_pass_platform_fee_to_buyer ?? false, }); } }, [organizerSettingsQuery.isFetched]); - const handleSubmit = (values: { default_attendee_details_collection_method: 'PER_TICKET' | 'PER_ORDER'; default_show_marketing_opt_in: boolean }) => { + const handleSubmit = (values: { default_attendee_details_collection_method: 'PER_TICKET' | 'PER_ORDER'; default_show_marketing_opt_in: boolean; default_pass_platform_fee_to_buyer: boolean }) => { updateMutation.mutate({ organizerSettings: values, organizerId: organizerId, @@ -87,6 +89,13 @@ export const EventDefaults = () => { {...form.getInputProps('default_show_marketing_opt_in', {type: 'checkbox'})} /> + +