<?php namespace QUI\ERP\Coupons; use Exception; use QUI; use QUI\ERP\Accounting\Calc as ErpCalc; use QUI\ERP\Coupons\Handler as CouponsHandler; use QUI\ERP\Coupons\Products\CouponProductException; use QUI\ERP\Coupons\Products\DigitalCouponProductType; use QUI\ERP\Coupons\Products\Handler as CouponProductsHandler; use QUI\ERP\Coupons\Products\PhysicalCouponProductType; use QUI\ERP\Discount\EventHandling as DiscountEvents; use QUI\ERP\Order\AbstractOrder; use QUI\ERP\Order\Basket\Basket; use QUI\ERP\Products\Handler\Fields; use QUI\ERP\Products\Interfaces\ProductInterface; use QUI\Smarty\Collector; use QUI\System\Console\Tools\MigrationV2; use function array_merge; use function array_search; use function array_unique; use function implode; use function in_array; use function is_array; use function is_numeric; use function is_string; use function json_decode; use function json_encode; /** * Class Events * * Global Event Handler for quiqqer/payment-paypal */ class Events { /** * quiqqer/core: onPackageSetup * * @param QUI\Package\Package $Package * @return void */ public static function onPackageSetup(QUI\Package\Package $Package): void { try { self::createProductFields(); } catch (Exception $Exception) { QUI\System\Log::writeException($Exception); } } /** * event : on admin load footer */ public static function onAdminLoadFooter(): void { echo '<script src="' . URL_OPT_DIR . 'quiqqer/coupons/bin/backend/load.js"></script>'; } /** * Template event quiqqer/order: onQuiqqer::order::orderProcessBasketEnd * * @param Collector $Collector * @param mixed $Basket * @param AbstractOrder|null $Order */ public static function templateOrderProcessBasketEnd( Collector $Collector, mixed $Basket, AbstractOrder $Order = null ): void { if ( !($Basket instanceof Basket) && !($Basket instanceof QUI\ERP\Order\Basket\BasketOrder) ) { return; } if (isset($Order) && isset($_GET['coupon'])) { try { $code = Handler::sanitizeCode($_GET['coupon']); $CouponCode = Handler::getCouponCodeByCode($code); $CouponCode->checkRedemption(QUI::getUserBySession()); if ($Order instanceof QUI\ERP\Order\OrderInProcess) { $CouponCode->addToOrder($Order); } } catch (Exception) { } } $Collector->append( '<div data-qui="package/quiqqer/coupons/bin/frontend/controls/CouponCodeInput"></div>' ); } /** * @param QUI\ERP\Order\OrderProcess $OrderProcess * @throws QUI\ERP\Order\Exception * @throws QUI\Exception */ public static function onOrderProcess(QUI\ERP\Order\OrderProcess $OrderProcess): void { $CurrentStep = $OrderProcess->getCurrentStep(); $currentStep = $CurrentStep->getType(); if ($currentStep !== QUI\ERP\Order\Controls\OrderProcess\Basket::class) { return; } $coupons = QUI::getSession()->get('quiqqer-coupons'); if (is_string($coupons)) { $coupons = json_decode($coupons, true); } if (!is_array($coupons)) { $coupons = []; } if (isset($_GET['coupon'])) { $coupons[] = $_GET['coupon']; } $coupons = array_unique($coupons); if (empty($coupons)) { return; } $Order = $OrderProcess->getOrder(); foreach ($coupons as $coupon) { self::addCouponToOrder($Order, $coupon); } } /** * @param QUI\ERP\Order\Basket\Basket $Basket * @param $pos */ public static function onQuiqqerOrderBasketRemovePos( QUI\ERP\Order\Basket\Basket $Basket, $pos ): void { $Order = null; try { $Order = $Basket->getOrder(); } catch (QUI\Exception) { $Orders = QUI\ERP\Order\Handler::getInstance(); try { $Order = $Orders->getLastOrderInProcessFromUser(QUI::getUserBySession()); } catch (QUI\Exception) { } } if (!$Order) { QUI::getSession()->remove('quiqqer-coupons'); return; } $Article = $Order->getArticles()->getArticle($pos); if (!$Article) { return; } $customData = $Article->getCustomData(); $orderCoupons = $Order->getDataEntry('quiqqer-coupons'); $articleCouponCode = false; if (isset($customData['package']) && isset($customData['code'])) { $articleCouponCode = $customData['code']; } if (!$articleCouponCode) { return; } // custom data has code params, so article is a coupon code // we need to delete it if (in_array($articleCouponCode, $orderCoupons)) { $pos = array_search($articleCouponCode, $orderCoupons); unset($orderCoupons[$pos]); $Order->setData('quiqqer-coupons', $orderCoupons); try { $Order->save(); } catch (QUI\Exception) { } } // look at session coupons // we need to delete it $coupons = QUI::getSession()->get('quiqqer-coupons'); if (is_string($coupons)) { $coupons = json_decode($coupons, true); } if (!is_array($coupons) || empty($coupons)) { return; } if (!in_array($customData['code'], $coupons)) { return; } // remove code from session // because code is deleted $newCouponList = []; foreach ($coupons as $coupon) { if ($customData['code'] !== $coupon) { $newCouponList[] = $coupon; } } if (empty($newCouponList)) { QUI::getSession()->remove('quiqqer-coupons'); } else { QUI::getSession()->set('quiqqer-coupons', json_encode($newCouponList)); } } /** * event - on price factor init * * @param mixed $Basket * @param QUI\ERP\Order\AbstractOrder $Order * @param QUI\ERP\Products\Product\ProductList $Products * @throws QUI\Exception */ public static function onQuiqqerOrderBasketToOrder( mixed $Basket, QUI\ERP\Order\AbstractOrder $Order, QUI\ERP\Products\Product\ProductList $Products ): void { $coupons = $Order->getDataEntry('quiqqer-coupons'); $sessionCoupons = QUI::getSession()->get('quiqqer-coupons'); if (!is_array($coupons)) { $coupons = []; } if (is_string($sessionCoupons)) { $sessionCoupons = json_decode($sessionCoupons, true); if (is_array($sessionCoupons)) { $coupons = array_merge($coupons, $sessionCoupons); } $coupons = array_unique($coupons); self::addSessionCouponsToOrder($Order, $sessionCoupons); } if (empty($coupons)) { return; } $PriceFactors = $Products->getPriceFactors(); $products = $Products->toArray(); $productCount = $Products->count(); $subSum = $products['calculations']['subSum']; $checkRedeemable = !$Order->isSuccessful(); // if order is successful we don't need a check $OrderInProcess = $Order->getAttribute('OrderInProcess'); $added = false; if ($Order->getAttribute('inOrderCreation')) { $checkRedeemable = false; } if ( $OrderInProcess instanceof QUI\ERP\Order\OrderInProcess && $OrderInProcess->getAttribute('inOrderCreation') ) { $checkRedeemable = false; } foreach ($coupons as $coupon) { /* @var $Coupon CouponCode */ try { $Coupon = Handler::getCouponCodeByCode($coupon); } catch (Exception) { continue; } // coupon check if ($checkRedeemable && !$Coupon->isRedeemable($Order->getCustomer())) { continue; } /* @var $Discount QUI\ERP\Discount\Discount */ $discounts = $Coupon->getDiscounts(); foreach ($discounts as $Discount) { if (!DiscountEvents::isDiscountUsableWithQuantity($Discount, $productCount)) { continue; } if ($Discount->getAttribute('scope') === QUI\ERP\Discount\Handler::DISCOUNT_SCOPE_GRAND_TOTAL) { // do nothing for this scope // since this scope requires all price factors etc., this cannot be calculated here } elseif (!DiscountEvents::isDiscountUsableWithPurchaseValue($Discount, $subSum)) { continue; } $PriceFactor = $Discount->toPriceFactor(null, $Order->getCustomer()); $PriceFactor->setTitle( QUI::getLocale()->get('quiqqer/coupons', 'coupon.discount.title', [ 'code' => $Coupon->getCode() ]) ); $scope = $Discount->getAttribute('scope'); $isUnique = $scope === QUI\ERP\Discount\Handler::DISCOUNT_SCOPE_UNIQUE; $everyProduct = $scope === QUI\ERP\Discount\Handler::DISCOUNT_SCOPE_EVERY_PRODUCT; if ($everyProduct || $isUnique) { // add to the product $products = $Products->getProducts(); $alreadyAdded = false; foreach ($products as $Product) { if ($Discount->canUsedWith($Product) === false) { continue; } if ($isUnique && $alreadyAdded) { continue; } if ($Product instanceof QUI\ERP\Products\Product\UniqueProduct) { $Product->getPriceFactors()->add($PriceFactor); $added = true; $alreadyAdded = true; } } continue; } $added = true; $PriceFactors->addToEnd($PriceFactor); } } if ($added) { try { $Products->recalculation(); } catch (QUI\Exception $Exception) { QUI\System\Log::writeDebugException($Exception); } } } /** * quiqqer/order: onQuiqqerOrderSuccessful * * Redeem coupons used in (completed) orders * * @param AbstractOrder $Order * @return void */ public static function onQuiqqerOrderSuccessful(AbstractOrder $Order): void { $coupons = $Order->getDataEntry('quiqqer-coupons'); if (empty($coupons)) { return; } foreach ($coupons as $couponCode) { try { $CouponCode = CouponsHandler::getCouponCodeByCode($couponCode); $CouponCode->redeem($Order->getCustomer(), $Order); } catch (Exception $Exception) { QUI\System\Log::writeException($Exception); } } } /** * @param $Order * @param $coupons */ protected static function addSessionCouponsToOrder($Order, $coupons): void { if (!is_array($coupons)) { return; } // coupons as article if not added $Articles = $Order->getArticles(); $isInArticles = function ($code) use ($Articles) { foreach ($Articles as $Article) { $customData = $Article->getCustomData(); if (isset($customData['code']) && $customData['code'] === $code) { return true; } } return false; }; foreach ($coupons as $coupon) { if ($isInArticles($coupon) === false) { self::addCouponToOrder($Order, $coupon); } } } /** * @param $Order * @param $coupon */ protected static function addCouponToOrder($Order, $coupon): void { if (!($Order instanceof QUI\ERP\Order\OrderInProcess)) { return; } try { $code = Handler::sanitizeCode($coupon); $CouponCode = Handler::getCouponCodeByCode($code); $CouponCode->checkRedemption(QUI::getUserBySession()); $CouponCode->checkOrderRedemption($Order); $coupons = $Order->getDataEntry('quiqqer-coupons'); $coupons[] = $code; $coupons = array_unique($coupons); $Order->setData('quiqqer-coupons', $coupons); $Order->update(); $CouponCode->addToOrder($Order); } catch (Exception) { } } /** * Removes all coupons from the current session. * * @return void */ public static function removeCouponsFromSession(): void { QUI::getSession()->remove('quiqqer-coupons'); } /** * Create all fixed product fields that quiqqer/stock-management provides * * @return void * @throws QUI\Exception */ protected static function createProductFields(): void { $fields = [ CouponProductsHandler::PRODUCT_FIELD_ID_TRANSFERABLE => [ 'title' => [ 'de' => 'Gutschein-Code ist übertragbar', 'en' => 'Coupon code is transferable' ], 'description' => [ 'de' => 'Übertragbare Gutscheine sind auch von anderen Personen als dem Käufer einlösbar.' . ' Nicht übertragbare Gutscheine können nur vom Käufer eingelöst werden, wenn dieser' . ' eingeloggt ist.', 'en' => 'Transferable coupons are also redeemable by persons other than the buyer.' . ' Non-transferable vouchers can only be redeemed by the buyer when logged in.' ], 'type' => Fields::TYPE_BOOL, 'public' => false, 'standard' => false, 'requiredField' => false ], CouponProductsHandler::PRODUCT_FIELD_ID_SEND_MAIL => [ 'title' => [ 'de' => 'Gutschein-Code per E-Mail senden', 'en' => 'Send coupon code via email' ], 'description' => [ 'de' => 'Der Gutschein-Code wird dem Käufer per E-Mail gesendet. Gilt nicht, wenn der Benutzer zw.' . ' Post- und Mail-Versand wählen kann und den Postversand auswählt.' . ' Wird der Gutschein auch als PDF-Datei generiert, wird die PDF-Datei an diese E-Mail angehanden.', 'en' => 'The coupon code is sent to the buyer via email. It is not sent if the customer is able to' . ' choose between email and mail delivery type and chooses delivery by mail.' . ' If the coupon is also generated as a PDF file, the file is attached to this email.' ], 'type' => Fields::TYPE_BOOL, 'public' => false, 'standard' => false, 'requiredField' => false ], CouponProductsHandler::PRODUCT_FIELD_ID_GENERATE_PDF => [ 'title' => [ 'de' => 'Gutschein-Code als PDF bereitstellen', 'en' => 'Provide coupon code as PDF' ], 'description' => [ 'de' => 'Der Gutschein wird auch als PDF-Datei erstellt und dem Käufer (je nach Wahl) per E-Mail gesendet.' . ' Zusätzlich wird die PDF-Datei dem Käufer in seinem Frontend-Profil (sofern eingerichtet)' . ' bereitgestellt.', 'en' => 'The coupon is also generated as a PDF file and is sent to the customer (depending on choice)' . ' via email. Additionally, the PDF file is made available via the customers frontend profile' . ' (if set up).' ], 'type' => Fields::TYPE_BOOL, 'public' => false, 'standard' => false, 'requiredField' => false ], CouponProductsHandler::PRODUCT_FIELD_ID_COUPON_AMOUNT => [ 'title' => [ 'de' => 'Gutschein Wert', 'en' => 'Coupon amount' ], 'type' => Fields::TYPE_FLOAT, 'public' => false, 'standard' => false, 'requiredField' => true ], CouponProductsHandler::PRODUCT_FIELD_ID_DAYS_VALID => [ 'title' => [ 'de' => 'Gutschein-Code Gültigkeit (Tage)', 'en' => 'Coupon code validity (days)' ], 'type' => Fields::TYPE_INT, 'public' => false, 'standard' => false, 'requiredField' => true ], CouponProductsHandler::PRODUCT_FIELD_ID_COUPON_DESCRIPTION => [ 'title' => [ 'de' => 'Gutschein-Beschreibung', 'en' => 'Coupon description' ], 'description' => [ 'de' => 'Diese Beschreibung taucht unter dem Titel "GUTSCHEIN" auf der generierten PDF-Datei auf.', 'en' => 'This description appears under the title caption "COUPON" on the generated PDF file.' ], 'type' => Fields::TYPE_INPUT_MULTI_LANG, 'public' => false, 'standard' => false, 'requiredField' => false ], CouponProductsHandler::PRODUCT_FIELD_ID_IS_SINGLE_PURPOSE_COUPON => [ 'title' => [ 'de' => 'Ist Einzweck-Gutschein (Besteuerung bei Gutschein-Kauf)', 'en' => 'Is single purpose coupon (taxation on voucher purchase)' ], 'description' => [ 'de' => 'Einzweck-Gutscheine sind solche, bei denen die Besteuerung und der Leistungsort bereits' . ' beim Gutschein-Kauf feststehen. Beispiel: Gutschein für eine Massage in einem Spa.' . ' Alles andere (wie z.B. Wertgutscheine für den Einsatz unabhängig vom Artikel) sind' . ' Mehrzweck-Gutscheine und werden bei Einkauf nicht besteuert.', 'en' => 'Single-purpose coupons are those for which the taxation and place of performance are already' . ' determined at the time of coupon purchase. Example: voucher for a massage in a spa.' . ' Everything else (such as money value coupons for use regardless of the item) are' . ' multi-purpose coupons and are not taxed at the time of purchase.' ], 'type' => Fields::TYPE_BOOL, 'public' => false, 'standard' => false, 'requiredField' => false ], CouponProductsHandler::PRODUCT_FIELD_ID_USER_DELIVERY_TYPE_SELECT => [ 'title' => [ 'de' => 'Gutschein - Versand', 'en' => 'Coupon delivery' ], 'type' => Fields::TYPE_ATTRIBUTE_LIST, 'public' => true, 'standard' => false, 'requiredField' => true, 'options' => [ 'entries' => [ [ 'title' => [ 'de' => 'per E - Mail', 'en' => 'via email' ], 'sum' => 0, 'type' => ErpCalc::CALCULATION_COMPLEMENT, 'selected' => true, 'userinput' => false ], [ 'title' => [ 'de' => 'per Post', 'en' => 'via mail' ], 'sum' => 0, 'type' => ErpCalc::CALCULATION_COMPLEMENT, 'selected' => false, 'userinput' => false ] ] ] ], CouponProductsHandler::PRODUCT_FIELD_ID_USER_DELIVERY_TYPE_SELECT_ALLOW => [ 'title' => [ 'de' => 'Kunde darf Gutschein - Versandart wählen', 'en' => 'Customer can choose coupon delivery type' ], 'description' => [ 'de' => 'Ist diese Funktion aktiviert, kann der Kunde beim Artikel im Shop wählen, ob der Gutschein' . ' per E - Mail oder Post versandt werden soll.', 'en' => 'if this option is enabled, the customer can choose at the article in the store whether the' . ' coupon should be sent by e - mail or by(physical) mail.' ], 'type' => Fields::TYPE_BOOL, 'public' => false, 'standard' => false, 'requiredField' => false ] ]; $fieldsCreated = false; foreach ($fields as $fieldId => $field) { try { Fields::getField($fieldId); continue; } catch (Exception) { // Field does not exist -> create it } try { Fields::createField([ 'id' => $fieldId, 'type' => $field['type'], 'titles' => $field['title'], 'workingtitles' => $field['title'], 'description' => !empty($field['description']) ? $field['description'] : null, 'systemField' => 0, 'standardField' => !empty($field['standard']) ? 1 : 0, 'publicField' => !empty($field['public']) ? 1 : 0, 'options' => !empty($field['options']) ? $field['options'] : null, 'requiredField' => !empty($field['requiredField']) ? 1 : 0 ]); } catch (Exception $Exception) { QUI\System\Log::writeException($Exception); continue; } $fieldsCreated = true; } if ($fieldsCreated) { QUI\Translator::publish('quiqqer / products'); } } /** * Assign plan product fields to a product * * @param ProductInterface $Product * @return void * * @throws QUI\Exception */ public static function onQuiqqerProductsProductCreate(ProductInterface $Product): void { if (!($Product instanceof DigitalCouponProductType) && !($Product instanceof PhysicalCouponProductType)) { return; } $isDigital = $Product instanceof DigitalCouponProductType; $UniqueProduct = $Product->createUniqueProduct(); $UniqueProduct->calc(); $fields = [ // CouponProductsHandler::PRODUCT_FIELD_ID_TRANSFERABLE => true, CouponProductsHandler::PRODUCT_FIELD_ID_COUPON_AMOUNT => $UniqueProduct->getPrice()->getValue(), CouponProductsHandler::PRODUCT_FIELD_ID_DAYS_VALID => 1095, // 3 years CouponProductsHandler::PRODUCT_FIELD_ID_IS_SINGLE_PURPOSE_COUPON => false ]; // Digital coupons get some extra fields if ($isDigital) { $fields[CouponProductsHandler::PRODUCT_FIELD_ID_SEND_MAIL] = true; $fields[CouponProductsHandler::PRODUCT_FIELD_ID_GENERATE_PDF] = true; $fields[CouponProductsHandler::PRODUCT_FIELD_ID_USER_DELIVERY_TYPE_SELECT] = false; $fields[CouponProductsHandler::PRODUCT_FIELD_ID_COUPON_DESCRIPTION] = null; } foreach ($fields as $fieldId => $value) { try { $Field = Fields::getField($fieldId); $Field->setValue($value); $Product->addOwnField($Field); } catch (Exception $Exception) { QUI\System\Log::writeException($Exception); } } // No VAT tax types -> Choose first one found $noVatTaxTypes = QUI\ERP\Coupons\Products\Handler::getNoVatTaxTypes(); if (!empty($noVatTaxTypes)) { $Product->getField(Fields::FIELD_VAT)->setValue($noVatTaxTypes[0]->getId()); } try { $Product->update(QUI::getUsers()->getSystemUser()); } catch (Exception $Exception) { QUI\System\Log::writeException($Exception); } } /** * quiqqer/products: onQuiqqerProductsProductActivate * * Check if a coupon product has the correct tax type! * * @param ProductInterface $Product * * @throws CouponProductException * @throws QUI\Exception */ public static function onQuiqqerProductsProductActivate(ProductInterface $Product): void { if (!($Product instanceof DigitalCouponProductType) && !($Product instanceof PhysicalCouponProductType)) { return; } $isSinglePurposeCoupon = $Product->getFieldValue( CouponProductsHandler::PRODUCT_FIELD_ID_IS_SINGLE_PURPOSE_COUPON ); if (!empty($isSinglePurposeCoupon)) { return; } $productTaxTypeId = (int)$Product->getFieldValue(Fields::FIELD_VAT); $noVatTaxTypes = QUI\ERP\Coupons\Products\Handler::getNoVatTaxTypes(); foreach ($noVatTaxTypes as $TaxType) { if ($TaxType->getId() === $productTaxTypeId) { return; } } throw new CouponProductException([ 'quiqqer / coupons', 'exception.CouponProduct.no_vat_tax_type_required', [ 'productTitle' => $Product->getTitle(), 'productId' => $Product->getId() ] ]); } /** * quiqqer/order: onQuiqqerOrderCreated * * Parse coupon attributes from order and create coupon codes for the buyer. * * @param AbstractOrder $Order * @return void */ public static function onQuiqqerOrderCreated(AbstractOrder $Order): void { QUI\ERP\Coupons\Products\Handler::createCouponCodesFromOrder($Order); } public static function onQuiqqerMigrationV2(MigrationV2 $Console): void { $Console->writeLn('- Migrate coupons'); $table = QUI::getDBTableName('quiqqer_coupons'); $result = QUI::getDataBase()->fetch([ 'from' => $table ]); foreach ($result as $entry) { $id = $entry['id']; $userIds = $entry['userIds']; $groupIds = $entry['groupIds']; if (!empty($userIds)) { $userIds = json_decode($userIds, true) ?? []; foreach ($userIds as $k => $userId) { if (is_numeric($userId)) { try { $userIds[$k] = QUI::getUsers()->get($userId)->getUUID(); } catch (QUI\Exception) { } } } QUI::getDataBase()->update( $table, ['userIds' => json_encode($userIds)], ['id' => $id] ); } if (!empty($groupIds)) { $groupIds = json_decode($groupIds, true) ?? []; foreach ($groupIds as $k => $groupId) { if (is_numeric($groupId)) { try { $groupIds[$k] = QUI::getGroups()->get($groupId)->getUUID(); } catch (QUI\Exception) { } } } QUI::getDataBase()->update( $table, ['groupIds' => json_encode($groupIds)], ['id' => $id] ); } } } }