├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── composer.json ├── src ├── Api │ ├── ConfigInterface.php │ ├── CustomOrderFeesRepositoryInterface.php │ └── Data │ │ └── CustomOrderFeesInterface.php ├── Block │ ├── Adminhtml │ │ └── CustomOrderFees │ │ │ ├── Report.php │ │ │ └── Report │ │ │ ├── Filter │ │ │ └── Form │ │ │ │ └── CustomOrderFees.php │ │ │ ├── Grid.php │ │ │ └── Grid │ │ │ └── Column │ │ │ └── Renderer │ │ │ └── Currency │ │ │ └── LocalizedFeeAmount.php │ ├── Sales │ │ ├── Cart │ │ │ └── Totals │ │ │ │ └── CustomFees.php │ │ └── Order │ │ │ └── Totals.php │ └── System │ │ └── Config │ │ └── Form │ │ └── Field │ │ └── CustomFees.php ├── Controller │ └── Adminhtml │ │ └── Report │ │ ├── CustomOrderFees.php │ │ └── CustomOrderFees │ │ └── Export │ │ ├── Csv.php │ │ └── Excel.php ├── Model │ ├── Checkout │ │ └── ConfigProvider │ │ │ └── CustomFees.php │ ├── Config.php │ ├── CustomOrderFees.php │ ├── CustomOrderFeesRepository.php │ ├── ResourceModel │ │ ├── CustomOrderFees.php │ │ ├── CustomOrderFees │ │ │ └── Collection.php │ │ └── Report │ │ │ ├── CustomOrderFees.php │ │ │ └── CustomOrderFees │ │ │ └── Collection.php │ ├── Sales │ │ └── Pdf │ │ │ └── CustomFees.php │ └── Total │ │ ├── Creditmemo │ │ └── CustomFees.php │ │ ├── Invoice │ │ └── CustomFees.php │ │ └── Quote │ │ └── CustomFees.php ├── Observer │ └── BeforeQuoteSubmitObserver.php ├── Plugin │ ├── Framework │ │ └── View │ │ │ └── Element │ │ │ └── UiComponent │ │ │ └── DataProvider │ │ │ ├── CollectionFactoryPlugin.php │ │ │ └── DataProviderPlugin.php │ ├── Quote │ │ └── Api │ │ │ └── Data │ │ │ └── TotalsInterfacePlugin.php │ ├── Reports │ │ └── Model │ │ │ └── ResourceModel │ │ │ └── Refresh │ │ │ └── CollectionPlugin.php │ ├── Sales │ │ ├── Api │ │ │ └── OrderRepositoryInterfacePlugin.php │ │ └── Block │ │ │ └── Order │ │ │ └── TotalsPlugin.php │ └── Ui │ │ └── Component │ │ └── AbstractComponentPlugin.php ├── Service │ └── CustomFeesRetriever.php ├── ViewModel │ └── CustomFees.php ├── etc │ ├── acl.xml │ ├── adminhtml │ │ ├── di.xml │ │ ├── menu.xml │ │ ├── routes.xml │ │ └── system.xml │ ├── config.xml │ ├── db_schema.xml │ ├── db_schema_whitelist.json │ ├── di.xml │ ├── events.xml │ ├── extension_attributes.xml │ ├── frontend │ │ └── di.xml │ ├── module.xml │ ├── pdf.xml │ └── sales.xml ├── i18n │ └── en_US.csv ├── registration.php └── view │ ├── adminhtml │ └── layout │ │ ├── custom_fees_report_customorderfees.xml │ │ ├── sales_order_creditmemo_new.xml │ │ ├── sales_order_creditmemo_updateqty.xml │ │ ├── sales_order_creditmemo_view.xml │ │ ├── sales_order_invoice_new.xml │ │ ├── sales_order_invoice_updateqty.xml │ │ ├── sales_order_invoice_view.xml │ │ └── sales_order_view.xml │ └── frontend │ ├── layout │ ├── checkout_cart_index.xml │ ├── checkout_index_index.xml │ ├── hyva_checkout_cart_index.xml │ ├── hyva_checkout_components.xml │ ├── sales_email_order_creditmemo_items.xml │ ├── sales_email_order_invoice_items.xml │ ├── sales_email_order_items.xml │ ├── sales_guest_creditmemo.xml │ ├── sales_guest_invoice.xml │ ├── sales_guest_print.xml │ ├── sales_guest_printcreditmemo.xml │ ├── sales_guest_printinvoice.xml │ ├── sales_guest_view.xml │ ├── sales_order_creditmemo.xml │ ├── sales_order_invoice.xml │ ├── sales_order_print.xml │ ├── sales_order_printcreditmemo.xml │ ├── sales_order_printinvoice.xml │ └── sales_order_view.xml │ ├── templates │ └── hyva │ │ ├── checkout │ │ └── price-summary │ │ │ └── total-segments │ │ │ └── custom_fees.phtml │ │ └── php-cart │ │ ├── totals.phtml │ │ └── totals │ │ ├── custom_fees-csp.phtml │ │ ├── custom_fees.phtml │ │ └── js │ │ └── custom_fees-js.phtml │ └── web │ ├── js │ └── view │ │ ├── cart │ │ └── totals │ │ │ └── custom_fees.js │ │ └── checkout │ │ └── summary │ │ └── custom_fees.js │ └── template │ └── checkout │ └── summary │ └── custom_fees.html └── test └── Integration ├── Block ├── Adminhtml │ └── CustomOrderFees │ │ └── Report │ │ └── GridTest.php └── Sales │ └── Order │ └── TotalsTest.php ├── Controller └── Adminhtml │ └── Report │ ├── CustomOrderFees │ └── Export │ │ ├── CsvTest.php │ │ └── ExcelTest.php │ └── CustomOrderFeesTest.php ├── ExtensionAttributesTest.php ├── Model ├── Checkout │ └── ConfigProvider │ │ └── CustomFeesTest.php ├── ResourceModel │ └── Report │ │ ├── CustomOrderFees │ │ └── CollectionTest.php │ │ └── CustomOrderFeesTest.php ├── Sales │ └── Pdf │ │ └── CustomFeesTest.php └── Total │ ├── Creditmemo │ └── CustomFeesTest.php │ ├── Invoice │ └── CustomFeesTest.php │ └── Quote │ └── CustomFeesTest.php ├── Observer └── BeforeQuoteSubmitObserverTest.php ├── Plugin ├── Framework │ └── View │ │ └── Element │ │ └── UiComponent │ │ └── DataProvider │ │ ├── CollectionFactoryPluginTest.php │ │ └── DataProviderPluginTest.php ├── Quote │ └── Api │ │ └── Data │ │ └── TotalsInterfacePluginTest.php ├── Reports │ └── Model │ │ └── ResourceModel │ │ └── Refresh │ │ └── CollectionPluginTest.php ├── Sales │ ├── Api │ │ └── OrderRepositoryInterfacePluginTest.php │ └── Block │ │ └── Order │ │ └── TotalsPluginTest.php └── Ui │ └── Component │ └── AbstractComponentPluginTest.php ├── Service └── CustomFeesRetrieverTest.php └── _files ├── aggregated_custom_order_fees.php ├── aggregated_custom_order_fees_rollback.php ├── creditmemo.php ├── creditmemo_rollback.php ├── creditmemo_with_custom_fees.php ├── creditmemo_with_custom_fees_rollback.php ├── custom_fees_rollback.php ├── invoice.php ├── invoice_rollback.php ├── invoice_with_custom_fees.php ├── invoice_with_custom_fees_rollback.php ├── order.php ├── order_rollback.php ├── order_with_custom_fees.php ├── order_with_custom_fees_rollback.php ├── order_with_example_custom_fee.php ├── order_with_example_custom_fee_rollback.php ├── orders_with_custom_fees.php ├── orders_with_custom_fees_multicurrency.php ├── orders_with_custom_fees_multicurrency_rollback.php └── orders_with_custom_fees_rollback.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog for Custom Fees for Magento 2 by Joseph Leedy 2 | 3 | All notable changes to this extension will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog], and this extension adheres to 6 | [Semantic Versioning]. 7 | 8 | For more information about this extension, please refer to the [README] 9 | document. 10 | 11 | ## [Unreleased] 12 | 13 | ## [1.1.1] 14 | 15 | ### Fixed 16 | 17 | - Softened dependency on Zend Framework 1 Database component to fix 18 | incompatibility with Magento 2.4.4 and 2.4.5 19 | 20 | ## [1.1.0] 21 | 22 | ### Added 23 | 24 | - Custom fees are now rendered in columns in the Sales Order Grid in the Admin 25 | panel 26 | - A report summarizing the total amount of collected custom order fees can be 27 | generated from the Admin panel 28 | - Custom fees are now rendered on the _Cart_ page in the Hyvä frontend 29 | - Custom fees are now rendered on the _Checkout_ pages in the Hyvä frontend 30 | 31 | ### Fixed 32 | 33 | - Reorded custom fees totals to be placed _after_ tax totals on customer and 34 | guest order, invoice and credit memo pages in Hyvä frontend 35 | 36 | ## [1.0.2] 37 | 38 | ### Fixed 39 | 40 | - Mark constructor parameters as explicitly nullable in custom order fees model 41 | to fix deprecation errors thrown by PHP 8.4 42 | 43 | ### Changed 44 | 45 | - Moved the _Custom Fees_ configuration field to be placed after the _Tax_ 46 | field in the _Totals Sort Order_ group 47 | 48 | ## [1.0.1] 49 | 50 | ### Fixed 51 | 52 | - Narrowed type for `custom_fees` Cart extension attribute to fix Swagger error 53 | - Added and updated method annotations in Custom Order Fees Interface to fix 54 | Swagger errors 55 | 56 | ## [1.0.0] 57 | 58 | ### Added 59 | 60 | - Initial version of extension 61 | 62 | [Keep a Changelog]: https://keepachangelog.com/en/1.1.0 63 | [Semantic Versioning]: https://semver.org/spec/v2.0.0.html 64 | [README]: ./README.md 65 | [Unreleased]: https://github.com/JosephLeedy/magento2-module-custom-fees/compare/1.1.1...HEAD 66 | [1.1.1]: https://github.com/JosephLeedy/magento2-module-custom-fees/releases/tag/1.1.1 67 | [1.1.0]: https://github.com/JosephLeedy/magento2-module-custom-fees/releases/tag/1.1.0 68 | [1.0.2]: https://github.com/JosephLeedy/magento2-module-custom-fees/releases/tag/1.0.2 69 | [1.0.1]: https://github.com/JosephLeedy/magento2-module-custom-fees/releases/tag/1.0.1 70 | [1.0.0]: https://github.com/JosephLeedy/magento2-module-custom-fees/releases/tag/1.0.0 71 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy for Custom Fees for Magento 2 by Joseph Leedy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | |---------| ------------------ | 7 | | 1.0.x | :white_check_mark: | 8 | 9 | ## Reporting a Vulnerability 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please e-mail [security@josephleedy.dev] with the following details: 14 | 15 | * Type of issue (e.g. SQL injection, cross-site scripting, etc.) 16 | * Full paths of source code file(s) related to the manifestation of the issue 17 | * The location of the affected source code (tag, branch, commit or direct URL) 18 | * Any non-standard configuration required to reproduce the issue 19 | * Step-by-step instructions of how to reproduce the issue 20 | * Proof-of-concept or exploit code (if possible) 21 | * Impact of the issue, including how an attacker could exploit the issue 22 | * Magento or Adobe Commerce version 23 | * PHP version 24 | 25 | **Note**: Please verify if the issue is reproducible in a new Magento or Adobe 26 | Commerce environment without any extensions installed and with only default 27 | configurations. 28 | 29 | [security@josephleedy.dev]: mailto:security@josephleedy.dev 30 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "joseph-leedy/module-custom-fees", 3 | "version": "1.1.1", 4 | "description": "Adds configurable custom fees to orders", 5 | "type": "magento2-module", 6 | "require": { 7 | "php": "^8.1", 8 | "ext-json": "*", 9 | "ext-pcre": "*", 10 | "magento/framework": "~103.0.4", 11 | "magento/module-backend": "~102.0.4", 12 | "magento/module-checkout": "~100.4.4", 13 | "magento/module-config": "~101.2.4", 14 | "magento/module-quote": "~101.2.4", 15 | "magento/module-reports": "~100.4.4", 16 | "magento/module-sales": "~103.0.4", 17 | "magento/module-store": "~101.1.4", 18 | "magento/module-tax": "~100.4.4", 19 | "magento/module-ui": "~101.2.4" 20 | }, 21 | "require-dev": { 22 | "squizlabs/php_codesniffer": "^3.10", 23 | "dealerdirect/phpcodesniffer-composer-installer": "^1.0", 24 | "magento/magento-coding-standard": "*", 25 | "phpstan/phpstan": "^1.12 || ^2.1", 26 | "phpstan/extension-installer": "^1.4", 27 | "bitexpert/phpstan-magento": "^v0.32 || ^v0.40", 28 | "phpunit/phpunit": "^9.6", 29 | "php-parallel-lint/php-parallel-lint": "^1.4" 30 | }, 31 | "suggest": { 32 | "magento/zendframework1": "Version 1.15 is used to interact with the database in Magento 2.4.4 and 2.4.5", 33 | "magento/zend-db": "Version 1.16 is used to interact with the database in Magento 2.4.6+" 34 | }, 35 | "license": [ 36 | "OSL-3.0" 37 | ], 38 | "autoload": { 39 | "files": [ 40 | "src/registration.php" 41 | ], 42 | "psr-4": { 43 | "JosephLeedy\\CustomFees\\": "src/" 44 | } 45 | }, 46 | "autoload-dev": { 47 | "psr-4": { 48 | "JosephLeedy\\CustomFees\\Test\\": "test/" 49 | } 50 | }, 51 | "authors": [ 52 | { 53 | "name": "Joseph Leedy", 54 | "email": "custom_fees+magento2@josephleedy.dev", 55 | "homepage": "https://github.com/JosephLeedy", 56 | "role": "developer" 57 | } 58 | ], 59 | "config": { 60 | "allow-plugins": { 61 | "dealerdirect/phpcodesniffer-composer-installer": true, 62 | "magento/composer-dependency-version-audit-plugin": true, 63 | "phpstan/extension-installer": true 64 | } 65 | }, 66 | "repositories": [ 67 | { 68 | "type": "composer", 69 | "url": "https://repo.magento.com/" 70 | } 71 | ] 72 | } 73 | -------------------------------------------------------------------------------- /src/Api/ConfigInterface.php: -------------------------------------------------------------------------------- 1 | $customFees 39 | * @throws InvalidArgumentException 40 | */ 41 | public function setCustomFees(string|array $customFees): CustomOrderFeesInterface; 42 | 43 | /** 44 | * @return string[]|float[] 45 | * @phpstan-return array{}|array 46 | */ 47 | public function getCustomFees(): array; 48 | 49 | /** 50 | * @return \Magento\Sales\Api\Data\OrderInterface|null 51 | */ 52 | public function getOrder(): ?OrderInterface; 53 | } 54 | -------------------------------------------------------------------------------- /src/Block/Adminhtml/CustomOrderFees/Report.php: -------------------------------------------------------------------------------- 1 | _blockGroup = 'JosephLeedy_CustomFees'; 18 | $this->_controller = 'adminhtml_customOrderFees_report'; 19 | $this->_headerText = (string) __('Total Custom Order Fees Report'); 20 | 21 | parent::_construct(); 22 | 23 | $this->buttonList->remove('add'); 24 | $this->addButton( 25 | 'filter_form_submit', 26 | [ 27 | 'label' => __('Show Report'), 28 | 'onclick' => 'filterFormSubmit()', 29 | 'class' => 'primary', 30 | ], 31 | ); 32 | } 33 | 34 | public function getFilterUrl(): string 35 | { 36 | /** @var RequestInterface&Request $request */ 37 | $request = $this->getRequest(); 38 | 39 | $request->setParam('filter', null); 40 | 41 | return $this->getUrl('*/*/customOrderFees', ['_current' => true]); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Block/Adminhtml/CustomOrderFees/Report/Filter/Form/CustomOrderFees.php: -------------------------------------------------------------------------------- 1 | getForm(); 16 | $fieldset = $form->getElement('base_fieldset'); 17 | 18 | $form->setData('action', $this->getUrl('*/*/customOrderFees')); 19 | 20 | if ($fieldset === null) { 21 | return $this; 22 | } 23 | 24 | $fieldset->removeField('report_type'); 25 | $fieldset->addField( 26 | 'show_base_amount', 27 | 'select', 28 | [ 29 | 'name' => 'show_base_amount', 30 | 'options' => [ 31 | '0' => __('No'), 32 | '1' => __('Yes'), 33 | ], 34 | 'label' => __('Show Base Amount'), 35 | ], 36 | ); 37 | 38 | return $this; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Block/Adminhtml/CustomOrderFees/Report/Grid/Column/Renderer/Currency/LocalizedFeeAmount.php: -------------------------------------------------------------------------------- 1 | getData('paid_order_currency'); 19 | 20 | if ($paidOrderCurrency !== null && !str_contains($paidOrderCurrency, ',')) { 21 | $this->getColumn()->setData('currency_code', $paidOrderCurrency); 22 | 23 | return parent::render($row); 24 | } 25 | 26 | return $this->renderAsList($row); 27 | } 28 | 29 | /** 30 | * @throws CurrencyException 31 | */ 32 | private function renderAsList(DataObject $row): string 33 | { 34 | /** @var string $index */ 35 | $index = $this->getColumn()->getIndex(); 36 | /** @var string $localizedFeeAmount */ 37 | $localizedFeeAmount = $row->getData($index); 38 | $localizedFeeAmounts = explode(',', $localizedFeeAmount); 39 | /** @var string $orderCurrency */ 40 | $orderCurrency = $row->getData('paid_order_currency'); 41 | $localizedOrderCurrencies = explode(', ', $orderCurrency); 42 | $localizedFeeAmountsWithCurrency = array_map( 43 | fn(string $localizedFeeAmount, int $key): string 44 | => $this 45 | ->_localeCurrency 46 | ->getCurrency($localizedOrderCurrencies[$key]) 47 | ->toCurrency((float) $localizedFeeAmount), 48 | $localizedFeeAmounts, 49 | array_keys($localizedFeeAmounts), 50 | ); 51 | 52 | return implode(', ', $localizedFeeAmountsWithCurrency); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Block/Sales/Cart/Totals/CustomFees.php: -------------------------------------------------------------------------------- 1 | _design->getDesignTheme()->getCode() !== 'Hyva/default-csp' 17 | && $this->_design->getDesignTheme()->getParentTheme()?->getCode() !== 'Hyva/default-csp' 18 | ) { 19 | return $this; 20 | } 21 | 22 | $this->setTemplate('JosephLeedy_CustomFees::hyva/php-cart/totals/custom_fees-csp.phtml'); 23 | 24 | /** @var Template $cspJsBlock */ 25 | $cspJsBlock = $this->getLayout()->addBlock(Template::class, 'custom_fees.js', 'checkout.cart.totals.scripts'); 26 | 27 | $cspJsBlock->setTemplate('JosephLeedy_CustomFees::hyva/php-cart/totals/js/custom_fees-js.phtml'); 28 | 29 | return $this; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Block/Sales/Order/Totals.php: -------------------------------------------------------------------------------- 1 | getParentBlock()->getSource(); 49 | } 50 | 51 | public function initTotals(): self 52 | { 53 | $source = $this->getSource(); 54 | $order = match (true) { 55 | $source instanceof Order => $source, 56 | $source instanceof Invoice, $source instanceof Creditmemo => $source->getOrder(), 57 | }; 58 | $customFees = $this->customFeesRetriever->retrieve($order); 59 | 60 | if (count($customFees) === 0) { 61 | return $this; 62 | } 63 | 64 | $firstFeeKey = array_key_first($customFees); 65 | $previousFeeCode = ''; 66 | 67 | array_walk( 68 | $customFees, 69 | function (array $customFee, string|int $key) use ($firstFeeKey, &$previousFeeCode) { 70 | $customFee['label'] = __($customFee['title']); 71 | 72 | unset($customFee['title']); 73 | 74 | /** @var DataObject $total */ 75 | $total = $this->dataObjectFactory->create( 76 | [ 77 | 'data' => $customFee 78 | ] 79 | ); 80 | 81 | if ($key === $firstFeeKey) { 82 | if ($this->getBeforeCondition() !== null) { 83 | $this->getParentBlock()->addTotalBefore($total, $this->getBeforeCondition()); 84 | } else { 85 | $this->getParentBlock()->addTotal($total, $this->getAfterCondition()); 86 | } 87 | } else { 88 | $this->getParentBlock()->addTotal($total, $previousFeeCode); 89 | } 90 | 91 | $previousFeeCode = $customFee['code']; 92 | } 93 | ); 94 | 95 | return $this; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Block/System/Config/Form/Field/CustomFees.php: -------------------------------------------------------------------------------- 1 | getStore(); 19 | $baseCurrency = $store?->getBaseCurrency()->getCurrencyCode() ?? ''; 20 | $valueColumnLabel = (string)__('Fee Amount'); 21 | 22 | if ($baseCurrency !== '') { 23 | $valueColumnLabel .= ' (' . $baseCurrency . ')'; 24 | } 25 | 26 | $this->addColumn( 27 | 'code', 28 | [ 29 | 'label' => __('Code'), 30 | 'class' => 'required-entry validate-code' 31 | ] 32 | ); 33 | $this->addColumn( 34 | 'title', 35 | [ 36 | 'label' => __('Fee Name'), 37 | 'class' => 'required-entry' 38 | ] 39 | ); 40 | $this->addColumn( 41 | 'value', 42 | [ 43 | 'label' => $valueColumnLabel, 44 | 'class' => 'required-entry validate-number validate-zero-or-greater' 45 | ] 46 | ); 47 | 48 | $this->_addAfter = false; 49 | $this->_addButtonLabel = (string)__('Add Custom Fee'); 50 | } 51 | 52 | private function getStore(): ?StoreInterface 53 | { 54 | /** @var int $storeId */ 55 | $storeId = $this->_request->getParam('store'); 56 | 57 | if ($storeId !== null) { 58 | try { 59 | $store = $this->_storeManager->getStore($storeId); 60 | } catch (NoSuchEntityException) { 61 | $store = null; 62 | } 63 | } else { 64 | /** @var int $websiteId */ 65 | $websiteId = $this->_request->getParam('website', 0); 66 | 67 | try { 68 | $store = $this->_storeManager->getWebsite($websiteId)->getDefaultStore(); 69 | } catch (NoSuchEntityException | DomainException) { 70 | $store = null; 71 | } 72 | } 73 | 74 | return $store; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Controller/Adminhtml/Report/CustomOrderFees.php: -------------------------------------------------------------------------------- 1 | showDatabaseServerVersionWarning(); 35 | $this->_showLastExecutionTime('report_custom_order_fees_aggregated', 'custom_order_fees'); 36 | 37 | $this 38 | ->_initAction() 39 | ->_setActiveMenu('JosephLeedy_CustomFees::custom_order_fees_report') 40 | ->_addBreadcrumb((string) __('Sales'), (string) __('Sales')) 41 | ->_addBreadcrumb((string) __('Custom Order Fees Report'), (string) __('Custom Order Fees Report')); 42 | $this 43 | ->_view 44 | ->getPage() 45 | ->getConfig() 46 | ->getTitle() 47 | ->prepend((string) __('Custom Order Fees Report')); 48 | 49 | $gridBlock = $this 50 | ->_view 51 | ->getLayout() 52 | ->getBlock('adminhtml_customOrderFees_report.grid'); 53 | $filterFormBlock = $this 54 | ->_view 55 | ->getLayout() 56 | ->getBlock('grid.filter.form'); 57 | 58 | $this->_initReportAction([$gridBlock, $filterFormBlock]); 59 | $this->_view->renderLayout(); 60 | } 61 | 62 | protected function _isAllowed(): bool 63 | { 64 | return $this->_authorization->isAllowed(self::ADMIN_RESOURCE); 65 | } 66 | 67 | private function showDatabaseServerVersionWarning(): void 68 | { 69 | $databaseServerVersion = $this->customOrderFeesReport->getDatabaseServerVersion(); 70 | $isDatabaseServerCompatible = $this->customOrderFeesReport->isDatabaseServerSupported($databaseServerVersion); 71 | 72 | if ($isDatabaseServerCompatible) { 73 | return; 74 | } 75 | 76 | $this->messageManager->addWarningMessage( 77 | (string) __( 78 | 'This report requires MySQL 8.0.4 or greater, MariaDB 10.6.0 or greater OR a MySQL 8.0-compatible ' 79 | . 'database server to generate properly. Your database server version "%1" is not compatible.', 80 | $databaseServerVersion, 81 | ), 82 | ); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Controller/Adminhtml/Report/CustomOrderFees/Export/Csv.php: -------------------------------------------------------------------------------- 1 | _view->getLayout()->createBlock(Grid::class); 19 | 20 | $this->_initReportAction($grid); 21 | 22 | return $this->_fileFactory->create($fileName, $grid->getCsvFile(), DirectoryList::VAR_DIR); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Controller/Adminhtml/Report/CustomOrderFees/Export/Excel.php: -------------------------------------------------------------------------------- 1 | _view->getLayout()->createBlock(Grid::class); 19 | 20 | $this->_initReportAction($grid); 21 | 22 | return $this->_fileFactory->create($fileName, $grid->getExcelFile(), DirectoryList::VAR_DIR); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Model/Checkout/ConfigProvider/CustomFees.php: -------------------------------------------------------------------------------- 1 | config->getCustomFees(); 25 | } catch (LocalizedException) { 26 | $customFees = []; 27 | } 28 | 29 | $codes = array_column($customFees, 'code'); 30 | 31 | return [ 32 | 'customFees' => [ 33 | 'codes' => $codes 34 | ] 35 | ]; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Model/Config.php: -------------------------------------------------------------------------------- 1 | storeManager->getStore()->getId(); 29 | } catch (NoSuchEntityException) { 30 | $storeId = null; 31 | } 32 | } 33 | 34 | /** @var string $customFeesJson */ 35 | $customFeesJson = $this->scopeConfig->getValue( 36 | self::CONFIG_PATH_CUSTOM_FEES, 37 | ScopeInterface::SCOPE_STORES, 38 | $storeId 39 | ) ?? '[]'; 40 | 41 | try { 42 | /** @var array{code: string, title: string, value: float}[] $customFees */ 43 | $customFees = $this->serializer->unserialize($customFeesJson); 44 | } catch (InvalidArgumentException $invalidArgumentException) { 45 | throw new LocalizedException( 46 | __('Could not get custom fees from configuration. Error: "%1"', $invalidArgumentException->getMessage()) 47 | ); 48 | } 49 | 50 | return $customFees; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Model/CustomOrderFees.php: -------------------------------------------------------------------------------- 1 | setData(self::ORDER_ID, (int)$orderId); 45 | 46 | return $this; 47 | } 48 | 49 | public function getOrderId(): ?int 50 | { 51 | /** @var int|string|null $orderId */ 52 | $orderId = $this->getData(self::ORDER_ID); 53 | 54 | if ($orderId !== null) { 55 | $orderId = (int)$orderId; 56 | } 57 | 58 | return $orderId; 59 | } 60 | 61 | public function setCustomFees(string|array $customFees): CustomOrderFeesInterface 62 | { 63 | if (is_string($customFees)) { 64 | try { 65 | $customFees = (array)( 66 | $this->serializer->unserialize($customFees) 67 | ?: throw new InvalidArgumentException((string)__('Invalid custom fees')) 68 | ); 69 | } catch (InvalidArgumentException) { 70 | throw new InvalidArgumentException((string)__('Invalid custom fees')); 71 | } 72 | } 73 | 74 | $this->setData(self::CUSTOM_FEES, $customFees); 75 | 76 | return $this; 77 | } 78 | 79 | public function getCustomFees(): array 80 | { 81 | /** 82 | * @var array|string|null $customFees 88 | */ 89 | $customFees = $this->getData(self::CUSTOM_FEES); 90 | 91 | if (is_string($customFees)) { 92 | $customFees = (array) $this->serializer->unserialize($customFees); 93 | } 94 | 95 | return $customFees ?? []; 96 | } 97 | 98 | public function getOrder(): ?OrderInterface 99 | { 100 | if ($this->order === null && $this->getOrderId() !== null) { 101 | try { 102 | /** @var ResourceModel $resource */ 103 | $resource = $this->_resource; 104 | $this->order = $resource->getOrder($this->getOrderId()); 105 | } catch (InvalidArgumentException) { // phpcs:ignore Magento2.CodeAnalysis.EmptyBlock.DetectedCatch 106 | // no-op 107 | } 108 | } 109 | 110 | return $this->order; 111 | } 112 | 113 | /** 114 | * Initialize Model 115 | */ 116 | protected function _construct(): void 117 | { 118 | $this->_init(ResourceModel::class); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Model/CustomOrderFeesRepository.php: -------------------------------------------------------------------------------- 1 | customOrderFeesFactory->create(); 39 | 40 | $this->resourceModel->load($customOrderFees, $id); 41 | 42 | if ($customOrderFees->getId() === null) { 43 | throw new NoSuchEntityException(__('Custom order fees with ID "%1" does not exist.', $id)); 44 | } 45 | 46 | return $customOrderFees; 47 | } 48 | 49 | public function getByOrderId(int|string $orderId): CustomOrderFeesInterface 50 | { 51 | $customOrderFees = $this->customOrderFeesFactory->create(); 52 | 53 | $this->resourceModel->load($customOrderFees, $orderId, 'order_entity_id'); 54 | 55 | if ($customOrderFees->getId() === null) { 56 | throw new NoSuchEntityException(__('Custom fees do not exist for order with ID "%1".', $orderId)); 57 | } 58 | 59 | return $customOrderFees; 60 | } 61 | 62 | public function getList(SearchCriteriaInterface $searchCriteria): SearchResultsInterface 63 | { 64 | /** @var Collection $collection */ 65 | $collection = $this->collectionFactory->create(); 66 | /** @var SearchResultsInterface $searchResults */ 67 | $searchResults = $this->searchResultsFactory->create(); 68 | 69 | try { 70 | $this->collectionProcessor->process($searchCriteria, $collection); 71 | } catch (InvalidArgumentException) { // phpcs:ignore Magento2.CodeAnalysis.EmptyBlock.DetectedCatch 72 | // No-op; this exception will never be thrown 73 | } 74 | 75 | $searchResults->setItems($collection->getItems()); 76 | $searchResults->setSearchCriteria($searchCriteria); 77 | $searchResults->setTotalCount($collection->getSize()); 78 | 79 | return $searchResults; 80 | } 81 | 82 | public function save(CustomOrderFeesInterface $customOrderFees): CustomOrderFeesInterface 83 | { 84 | try { 85 | $this->resourceModel->save($customOrderFees); 86 | } catch (AlreadyExistsException) { 87 | throw new AlreadyExistsException( 88 | __('Custom fees have already been saved for order with ID "%1".', $customOrderFees->getOrderId()) 89 | ); 90 | } catch (Exception $exception) { 91 | throw new CouldNotSaveException( 92 | __( 93 | 'Could not save custom fees for order with ID "%1". Error: "%2"', 94 | $customOrderFees->getOrderId(), 95 | $exception->getMessage() 96 | ) 97 | ); 98 | } 99 | 100 | return $customOrderFees; 101 | } 102 | 103 | public function delete(CustomOrderFeesInterface|int|string $customOrderFees): bool 104 | { 105 | if (!$customOrderFees instanceof CustomOrderFeesInterface) { 106 | $customOrderFees = $this->get($customOrderFees); 107 | } 108 | 109 | try { 110 | $this->resourceModel->delete($customOrderFees); 111 | } catch (Exception $exception) { 112 | throw new CouldNotDeleteException( 113 | __( 114 | 'Could not delete custom fees for order with ID "%1". Error: "%2"', 115 | $customOrderFees->getOrderId(), 116 | $exception->getMessage() 117 | ) 118 | ); 119 | } 120 | 121 | return true; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Model/ResourceModel/CustomOrderFees.php: -------------------------------------------------------------------------------- 1 | [ 28 | [], 29 | [] 30 | ] 31 | ]; 32 | /** 33 | * Mark `order_entity_id` field as unique 34 | * 35 | * @var array{array{field: string, title:string}} 36 | */ 37 | protected $_uniqueFields = [ 38 | [ 39 | 'field' => 'order_entity_id', 40 | 'title' => 'Custom fees order ID' 41 | ] 42 | ]; 43 | private OrderInterface $order; 44 | 45 | public function __construct( 46 | Context $context, 47 | private readonly OrderRepositoryInterface $orderRepository, 48 | $connectionName = null 49 | ) { 50 | parent::__construct($context, $connectionName); 51 | } 52 | 53 | /** 54 | * @throws InvalidArgumentException 55 | */ 56 | public function getOrder(int $orderId): OrderInterface 57 | { 58 | if (isset($this->order)) { 59 | return $this->order; 60 | } 61 | 62 | try { 63 | $this->order = $this->orderRepository->get($orderId); 64 | } catch (InputException) { // phpcs:ignore Magento2.CodeAnalysis.EmptyBlock.DetectedCatch 65 | // No-op; this exception will never be thrown 66 | } catch (NoSuchEntityException) { 67 | throw new InvalidArgumentException((string)__('Order with ID "%1" does not exist.', $orderId)); 68 | } 69 | 70 | return $this->order; 71 | } 72 | 73 | /** 74 | * Initialize resource model 75 | */ 76 | protected function _construct(): void 77 | { 78 | $this->_init(self::TABLE_NAME, 'id'); 79 | 80 | $this->_useIsObjectNew = true; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Model/ResourceModel/CustomOrderFees/Collection.php: -------------------------------------------------------------------------------- 1 | _init(Model::class, ResourceModel::class); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Model/ResourceModel/Report/CustomOrderFees.php: -------------------------------------------------------------------------------- 1 | _init(self::TABLE_NAME, 'id'); 27 | } 28 | 29 | /** 30 | * @throws LocalizedException 31 | * @throws Zend_Db_Statement_Exception 32 | * @throws Exception 33 | */ 34 | public function aggregate( 35 | string|DateTimeInterface|null $from = null, 36 | string|DateTimeInterface|null $to = null 37 | ): self { 38 | $databaseServerVersion = $this->getDatabaseServerVersion(); 39 | 40 | if (!$this->isDatabaseServerSupported($databaseServerVersion)) { 41 | throw new LocalizedException(__('Unsupported database server version "%1".', $databaseServerVersion)); 42 | } 43 | 44 | /** @var AdapterInterface $connection */ 45 | $connection = $this->getConnection(); 46 | 47 | $connection->beginTransaction(); 48 | 49 | try { 50 | $salesOrderTable = $this->getTable('sales_order'); 51 | $salesInvoiceTable = $this->getTable('sales_invoice'); 52 | $customOrderFeesTable = $this->getTable('custom_order_fees'); 53 | 54 | if ($from !== null || $to !== null) { 55 | $subSelect = $this->_getTableDateRangeSelect( 56 | $salesOrderTable, 57 | 'created_at', 58 | 'created_at', 59 | $from, 60 | $to 61 | ); 62 | } else { 63 | $subSelect = null; 64 | } 65 | 66 | $this->_clearTableByDateRange($this->getMainTable(), $from, $to, $subSelect); 67 | 68 | $periodExpression = $connection->getDatePartSql( 69 | $this->getStoreTZOffsetQuery( 70 | ['so' => $salesOrderTable], 71 | 'so.created_at', 72 | $from, 73 | $to 74 | ) 75 | ); 76 | // phpcs:disable Magento2.SQL.RawQuery.FoundRawSql 77 | $query = <<_makeConditionFromDateRangeSelect($subSelect, 'period') ?: '1=0'; 119 | $query .= "\nHAVING $periodCondition"; 120 | } 121 | 122 | $query .= ';'; 123 | 124 | $customOrderFees = $connection->query($query)->fetchAll(); 125 | 126 | if (count($customOrderFees) > 0) { 127 | $connection->insertMultiple($this->getMainTable(), $customOrderFees); 128 | } 129 | 130 | $connection->commit(); 131 | } catch (Exception $exception) { 132 | $connection->rollBack(); 133 | 134 | throw $exception; 135 | } 136 | 137 | $this->_setFlagData('report_custom_order_fees_aggregated'); 138 | 139 | return $this; 140 | } 141 | 142 | public function getDatabaseServerVersion(): string 143 | { 144 | /** @var AdapterInterface $connection */ 145 | $connection = $this->getConnection(); 146 | $version = $connection->fetchOne('SELECT VERSION();'); 147 | 148 | return $version; 149 | } 150 | 151 | public function isDatabaseServerSupported(string $version): bool 152 | { 153 | $isMatched = preg_match('/^[\d.]+/', $version, $matches); 154 | 155 | return match (true) { 156 | !$isMatched => false, 157 | str_contains(strtolower($version), 'mariadb') => version_compare($matches[0], '10.6.0', '>='), 158 | str_contains(strtolower($version), 'aurora') => version_compare(rtrim($matches[0], '.'), '8.0', '>='), 159 | default => version_compare($matches[0], '8.0.4', '>=') 160 | }; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/Model/ResourceModel/Report/CustomOrderFees/Collection.php: -------------------------------------------------------------------------------- 1 | |array{} 18 | */ 19 | private array $selectedColumns = []; 20 | 21 | public function _construct(): void 22 | { 23 | $this->_init(Item::class, CustomOrderFees::class); 24 | } 25 | 26 | public function addOrderStatusFilter(): self 27 | { 28 | return $this; 29 | } 30 | 31 | protected function _beforeLoad(): self 32 | { 33 | $this->getSelect()->from($this->getResource()->getMainTable(), $this->getSelectedColumns()); 34 | 35 | if (!$this->isTotals()) { 36 | $this 37 | ->getSelect() 38 | ->group($this->periodFormat) 39 | ->group('fee_title') 40 | ->group('paid_order_currency'); 41 | } 42 | 43 | if ($this->isTotals()) { 44 | $this->getSelect()->group('paid_order_currency'); 45 | } 46 | 47 | return parent::_beforeLoad(); 48 | } 49 | 50 | /** 51 | * @return array 52 | */ 53 | private function getSelectedColumns(): array 54 | { 55 | $connection = $this->getConnection(); 56 | 57 | if ('month' === $this->_period) { 58 | $this->periodFormat = $connection->getDateFormatSql('period', '%Y-%m'); 59 | } elseif ('year' === $this->_period) { 60 | $this->periodFormat = $connection->getDateExtractSql('period', AdapterInterface::INTERVAL_YEAR); 61 | } else { 62 | $this->periodFormat = $connection->getDateFormatSql('period', '%Y-%m-%d'); 63 | } 64 | 65 | if (!$this->isTotals()) { 66 | $this->selectedColumns = [ 67 | 'period' => $this->periodFormat, 68 | 'fee_title' => 'fee_title', 69 | 'base_fee_amount' => 'base_fee_amount', 70 | 'paid_fee_amount' => 'paid_fee_amount', 71 | 'paid_order_currency' => 'paid_order_currency', 72 | 'invoiced_fee_amount' => 'invoiced_fee_amount', 73 | ]; 74 | } 75 | 76 | if ($this->isTotals()) { 77 | /** @var array $aggregatedColumns */ 78 | $aggregatedColumns = $this->getAggregatedColumns(); 79 | $this->selectedColumns = $aggregatedColumns; 80 | $this->selectedColumns['paid_order_currency'] = 'paid_order_currency'; 81 | } 82 | 83 | return $this->selectedColumns; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Model/Sales/Pdf/CustomFees.php: -------------------------------------------------------------------------------- 1 | |null 32 | */ 33 | private array|null $customFees = null; 34 | 35 | /** 36 | * @param mixed[] $data 37 | */ 38 | public function __construct( 39 | Data $taxHelper, 40 | Calculation $taxCalculation, 41 | CollectionFactory $ordersFactory, 42 | private readonly CustomFeesRetriever $customFeesRetriever, 43 | array $data = [], 44 | ) { 45 | parent::__construct($taxHelper, $taxCalculation, $ordersFactory, $data); 46 | } 47 | 48 | /** 49 | * @return array{}|array{array{amount: string, label: Phrase, font_size: int}} 50 | */ 51 | public function getTotalsForDisplay(): array 52 | { 53 | $allCustomFees = $this->getCustomFees(); 54 | $totalCustomFeesAmount = array_sum(array_column($allCustomFees, 'value')); 55 | 56 | if ( 57 | count($allCustomFees) === 0 58 | || ( 59 | !filter_var($this->getDisplayZero(), FILTER_VALIDATE_BOOLEAN) 60 | && $totalCustomFeesAmount === 0 61 | ) 62 | ) { 63 | return []; 64 | } 65 | 66 | $fontSize = $this->getFontSize() ?? 7; 67 | $totals = array_map( 68 | fn (array $customFees): array => [ 69 | 'amount' => $this->getOrder()->formatPriceTxt($customFees['value']), 70 | 'label' => __($customFees['title']) . ':', 71 | 'font_size' => $fontSize 72 | ], 73 | $allCustomFees 74 | ); 75 | 76 | return $totals; 77 | } 78 | 79 | public function canDisplay(): bool 80 | { 81 | $allCustomFees = $this->getCustomFees(); 82 | $totalCustomFeesAmount = array_sum(array_column($allCustomFees, 'value')); 83 | 84 | return filter_var($this->getDisplayZero(), FILTER_VALIDATE_BOOLEAN) || $totalCustomFeesAmount !== 0; 85 | } 86 | 87 | /** 88 | * @return array{}|array 89 | */ 90 | private function getCustomFees(): array 91 | { 92 | if ($this->customFees !== null) { 93 | return $this->customFees; 94 | } 95 | 96 | $this->customFees = $this->customFeesRetriever->retrieve($this->getOrder()); 97 | 98 | return $this->customFees; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Model/Total/Creditmemo/CustomFees.php: -------------------------------------------------------------------------------- 1 | customFeesRetriever->retrieve($creditmemo->getOrder()); 31 | 32 | if (count($customFees) === 0) { 33 | return $this; 34 | } 35 | 36 | $baseTotalCustomFees = array_sum(array_column($customFees, 'base_value')); 37 | $totalCustomFees = array_sum(array_column($customFees, 'value')); 38 | 39 | $creditmemo->setBaseGrandTotal($creditmemo->getBaseGrandTotal() + $baseTotalCustomFees); 40 | $creditmemo->setGrandTotal($creditmemo->getGrandTotal() + $totalCustomFees); 41 | 42 | return $this; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Model/Total/Invoice/CustomFees.php: -------------------------------------------------------------------------------- 1 | customFeesRetriever->retrieve($invoice->getOrder()); 31 | 32 | if (count($customFees) === 0) { 33 | return $this; 34 | } 35 | 36 | $baseTotalCustomFees = array_sum(array_column($customFees, 'base_value')); 37 | $totalCustomFees = array_sum(array_column($customFees, 'value')); 38 | 39 | $invoice->setBaseGrandTotal($invoice->getBaseGrandTotal() + $baseTotalCustomFees); 40 | $invoice->setGrandTotal($invoice->getGrandTotal() + $totalCustomFees); 41 | 42 | return $this; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Model/Total/Quote/CustomFees.php: -------------------------------------------------------------------------------- 1 | setCode(self::CODE); 31 | } 32 | 33 | public function collect( 34 | Quote $quote, 35 | ShippingAssignmentInterface $shippingAssignment, 36 | Total $total 37 | ): CollectorInterface { 38 | parent::collect($quote, $shippingAssignment, $total); 39 | 40 | if (count($shippingAssignment->getItems()) === 0) { 41 | return $this; 42 | } 43 | 44 | [$baseCustomFees, $localCustomFees] = $this->getCustomFees($quote->getStore()); 45 | $customFees = $baseCustomFees; 46 | 47 | array_walk( 48 | $baseCustomFees, 49 | /** 50 | * @param array{code: string, title: string, value: float} $baseCustomFee 51 | */ 52 | static function (array $baseCustomFee, string|int $key) use ($total, &$customFees): void { 53 | $total->setBaseTotalAmount($baseCustomFee['code'], $baseCustomFee['value']); 54 | 55 | $customFees[$key]['base_value'] = $baseCustomFee['value']; 56 | } 57 | ); 58 | array_walk( 59 | $localCustomFees, 60 | /** 61 | * @param array{code: string, title: string, value: float} $localCustomFee 62 | */ 63 | static function (array $localCustomFee, string|int $key) use ($total, &$customFees): void { 64 | $total->setTotalAmount($localCustomFee['code'], $localCustomFee['value']); 65 | 66 | $customFees[$key]['value'] = $localCustomFee['value']; 67 | } 68 | ); 69 | 70 | $cartExtension = $quote->getExtensionAttributes(); 71 | 72 | if ($cartExtension === null) { 73 | return $this; 74 | } 75 | 76 | $cartExtension->setCustomFees($customFees); 77 | 78 | return $this; 79 | } 80 | 81 | /** 82 | * @return array{code: string, title: Phrase, value: float}[] 83 | */ 84 | public function fetch(Quote $quote, Total $total): array 85 | { 86 | [, $localCustomFees] = $this->getCustomFees($quote->getStore()); 87 | 88 | return $localCustomFees; 89 | } 90 | 91 | /** 92 | * @return array{code: string, title: Phrase, value: float}[][] 93 | */ 94 | private function getCustomFees(StoreInterface $store): array 95 | { 96 | $baseCustomFees = array_map( 97 | /** 98 | * @param array{code: string, title: string, value: float} $customFee 99 | * @return array{code: string, title: Phrase, value: float} 100 | */ 101 | static function (array $customFee): array { 102 | if ($customFee['code'] === 'example_fee') { 103 | return []; 104 | } 105 | 106 | $customFee['title'] = __($customFee['title']); 107 | 108 | return $customFee; 109 | }, 110 | $this->config->getCustomFees($store->getId()) 111 | ); 112 | $localCustomFees = array_map( 113 | /** 114 | * @param array{code: string, title: Phrase, value: float} $customFee 115 | * @return array{code: string, title: Phrase, value: float} 116 | */ 117 | function (array $customFee) use ($store): array { 118 | if (count($customFee) === 0) { 119 | return $customFee; 120 | } 121 | 122 | $customFee['value'] = $this->priceCurrency->convert($customFee['value'], $store); 123 | 124 | return $customFee; 125 | }, 126 | $baseCustomFees 127 | ); 128 | 129 | return [ 130 | array_filter($baseCustomFees), 131 | array_filter($localCustomFees) 132 | ]; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/Observer/BeforeQuoteSubmitObserver.php: -------------------------------------------------------------------------------- 1 | getEvent(); 31 | /** @var CartInterface $quote */ 32 | $quote = $event->getData('quote'); 33 | /** @var OrderInterface $order */ 34 | $order = $event->getData('order'); 35 | /** @var CartExtensionInterface $quoteExtension */ 36 | $quoteExtension = $quote->getExtensionAttributes(); 37 | /** @var array|null $customFees */ 38 | $customFees = $quoteExtension->getCustomFees(); 39 | /** @var OrderExtensionInterface $orderExtension */ 40 | $orderExtension = $order->getExtensionAttributes(); 41 | 42 | if ($customFees === null || count($customFees) === 0) { 43 | return; 44 | } 45 | 46 | /** @var CustomOrderFeesInterface $customOrderFees */ 47 | $customOrderFees = $this->customOrderFeesFactory->create(); 48 | 49 | $customOrderFees->setCustomFees($customFees); 50 | 51 | $orderExtension->setCustomOrderFees($customOrderFees); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Plugin/Framework/View/Element/UiComponent/DataProvider/CollectionFactoryPlugin.php: -------------------------------------------------------------------------------- 1 | getIdFieldName() : 'entity_id'; 25 | $customOrderFeesTable = $this->resource->getTable(CustomOrderFeesResource::TABLE_NAME); 26 | 27 | $result 28 | ->getSelect() 29 | ->joinLeft( 30 | $customOrderFeesTable, 31 | "$customOrderFeesTable.order_entity_id = main_table.$idFieldName", 32 | "$customOrderFeesTable.custom_fees" 33 | ); 34 | 35 | return $result; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Plugin/Framework/View/Element/UiComponent/DataProvider/DataProviderPlugin.php: -------------------------------------------------------------------------------- 1 | 30 | * } $result 31 | * @return array{items: array>} 32 | */ 33 | public function afterGetData(DataProvider $subject, array $result): array 34 | { 35 | if ($subject->getName() !== 'sales_order_grid_data_source') { 36 | return $result; 37 | } 38 | 39 | array_walk( 40 | $result['items'], 41 | /** 42 | * @param array{ 43 | * custom_fees: string|null, 44 | * store_id: string, 45 | * base_currency_code: string, 46 | * order_currency_code: string 47 | * } $orderData 48 | */ 49 | function (array &$orderData): void { 50 | $customFeesJson = $orderData['custom_fees']; 51 | 52 | if ($customFeesJson === null) { 53 | return; 54 | } 55 | 56 | try { 57 | /** 58 | * @var array $customFees 64 | */ 65 | $customFees = $this->serializer->unserialize($customFeesJson); 66 | } catch (InvalidArgumentException) { 67 | return; 68 | } 69 | 70 | array_walk( 71 | $customFees, 72 | /** 73 | * @param array{code: string, title: string, base_value: float, value: float} $customFee 74 | */ 75 | function (array $customFee) use (&$orderData): void { 76 | $orderData[$customFee['code'] . '_base'] = $this->priceCurrency->format( 77 | amount: $customFee['base_value'], 78 | includeContainer: false, 79 | scope: $orderData['store_id'], 80 | currency: $orderData['base_currency_code'] 81 | ); 82 | $orderData[$customFee['code']] = $this->priceCurrency->format( 83 | amount: $customFee['value'], 84 | includeContainer: false, 85 | scope: $orderData['store_id'], 86 | currency: $orderData['order_currency_code'] 87 | ); 88 | } 89 | ); 90 | } 91 | ); 92 | 93 | return $result; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Plugin/Quote/Api/Data/TotalsInterfacePlugin.php: -------------------------------------------------------------------------------- 1 | layout->getLayout()->getUpdate()->getHandles(); 44 | 45 | if ( 46 | $result === null 47 | || !in_array('hyva_checkout', $layoutHandles, true) 48 | || array_key_exists('custom_fees', $result) 49 | ) { 50 | return $result; 51 | } 52 | 53 | /** @var TotalSegmentInterface $customFeesTotalSegment */ 54 | $customFeesTotalSegment = $this->totalSegmentFactory->create(); 55 | /** @var TotalSegmentExtensionInterface $customFeesTotalSegmentExtension */ 56 | $customFeesTotalSegmentExtension = $this->totalSegmentExtensionFactory->create(); 57 | 58 | try { 59 | $customFees = $this->config->getCustomFees(); 60 | } catch (LocalizedException) { 61 | $customFees = []; 62 | } 63 | 64 | if (count($customFees) === 0) { 65 | return $result; 66 | } 67 | 68 | $customFeeCodes = array_column($customFees, 'code'); 69 | $customFeesTotalSegments = array_filter( 70 | $result, 71 | static fn(string $key): bool => in_array($key, $customFeeCodes, true), 72 | ARRAY_FILTER_USE_KEY, 73 | ); 74 | $result = array_diff_key($result, $customFeesTotalSegments); 75 | 76 | $customFeesTotalSegmentExtension->setCustomFeeSegments($customFeesTotalSegments); 77 | 78 | $customFeesTotalSegment->setCode('custom_fees'); 79 | $customFeesTotalSegment->setTitle((string) __('Custom Fees')); 80 | $customFeesTotalSegment->setValue(0); 81 | $customFeesTotalSegment->setArea(); 82 | $customFeesTotalSegment->setExtensionAttributes($customFeesTotalSegmentExtension); 83 | 84 | $result['custom_fees'] = $customFeesTotalSegment; 85 | 86 | return $result; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Plugin/Reports/Model/ResourceModel/Refresh/CollectionPlugin.php: -------------------------------------------------------------------------------- 1 | isAdded !== false) { 31 | return $result; 32 | } 33 | 34 | $flag = $this 35 | ->reportsFlagFactory 36 | ->create() 37 | ->setReportFlagCode('report_custom_order_fees_aggregated') 38 | ->loadSelf(); 39 | $item = $this->dataObjectFactory->create(); 40 | 41 | $item->setData( 42 | [ 43 | 'id' => 'custom_order_fees', 44 | 'report' => __('Custom Order Fees'), 45 | 'comment' => __('Total Custom Order Fees Report'), 46 | 'updated_at' => $flag->hasData() ? $flag->getLastUpdate() : '', 47 | ], 48 | ); 49 | 50 | $result->addItem($item); 51 | 52 | $this->isAdded = true; 53 | 54 | return $result; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Plugin/Sales/Api/OrderRepositoryInterfacePlugin.php: -------------------------------------------------------------------------------- 1 | getExtensionAttributes(); 24 | 25 | if ($orderExtension === null || $orderExtension->getCustomOrderFees() !== null) { 26 | return $result; 27 | } 28 | 29 | try { 30 | $customOrderFees = $this->customOrderFeesRepository->getByOrderId($id); 31 | } catch (NoSuchEntityException) { 32 | $customOrderFees = null; 33 | } 34 | 35 | if ($customOrderFees === null) { 36 | return $result; 37 | } 38 | 39 | $orderExtension->setCustomOrderFees($customOrderFees); 40 | 41 | return $result; 42 | } 43 | 44 | public function afterSave(OrderRepositoryInterface $subject, OrderInterface $result): OrderInterface 45 | { 46 | $orderExtension = $result->getExtensionAttributes(); 47 | 48 | if ($orderExtension === null || $orderExtension->getCustomOrderFees() === null) { 49 | return $result; 50 | } 51 | 52 | $customOrderFees = $orderExtension->getCustomOrderFees(); 53 | 54 | if (!$customOrderFees->hasDataChanges()) { 55 | return $result; 56 | } 57 | 58 | if ($customOrderFees->getOrderId() === null && $result->getEntityId() !== null) { 59 | $customOrderFees->setOrderId($result->getEntityId()); 60 | } 61 | 62 | $this->customOrderFeesRepository->save($customOrderFees); 63 | 64 | return $result; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Plugin/Sales/Block/Order/TotalsPlugin.php: -------------------------------------------------------------------------------- 1 | getLayout()->getUpdate()->getHandles(); 62 | } catch (LocalizedException) { 63 | return $result; 64 | } 65 | 66 | if (count(array_intersect($this->hyvaLayoutHandles, $layoutHandles)) === 0) { 67 | return $result; 68 | } 69 | 70 | $customOrderFees = $this->customFeesRetriever->retrieve($subject->getOrder()); 71 | 72 | if (count($customOrderFees) === 0) { 73 | return $result; 74 | } 75 | 76 | $customFeeCodes = array_column($customOrderFees, 'code'); 77 | $customFees = array_filter( 78 | $result, 79 | static fn(string $totalCode): bool => in_array($totalCode, $customFeeCodes, true), 80 | ARRAY_FILTER_USE_KEY, 81 | ); 82 | 83 | if (count($customFees) === 0) { 84 | return $result; 85 | } 86 | 87 | $resultWithoutCustomFees = array_diff_key($result, $customFees); 88 | $offset = array_search('tax', array_keys($resultWithoutCustomFees), true) + 1; 89 | 90 | return 91 | array_slice($resultWithoutCustomFees, 0, $offset, true) 92 | + $customFees 93 | + array_slice(array: $resultWithoutCustomFees, offset: $offset, preserve_keys: true); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Plugin/Ui/Component/AbstractComponentPlugin.php: -------------------------------------------------------------------------------- 1 | getData('name') !== 'sales_order_columns') { 27 | return; 28 | } 29 | 30 | /** @var SalesGridCollection $gridCollection */ 31 | $gridCollection = $subject->getContext()->getDataProvider()->getSearchResult(); 32 | /** @var array $uniqueCustomFeesLabelsByCode */ 33 | $uniqueCustomFeesLabelsByCode = []; 34 | 35 | /** 36 | * @var Order $order 37 | */ 38 | foreach ($gridCollection->getItems() as $order) { 39 | /** @var string|null $customFeesJson */ 40 | $customFeesJson = $order->getData('custom_fees'); 41 | 42 | if ($customFeesJson === null) { 43 | continue; 44 | } 45 | 46 | try { 47 | /** 48 | * @var array $customFees 49 | */ 50 | $customFees = $this->serializer->unserialize($customFeesJson); 51 | } catch (InvalidArgumentException) { 52 | continue; 53 | } 54 | 55 | foreach ($customFees as $customFee) { 56 | if (array_key_exists($customFee['code'], $uniqueCustomFeesLabelsByCode)) { 57 | continue; 58 | } 59 | 60 | $uniqueCustomFeesLabelsByCode[$customFee['code']] = $customFee['title']; 61 | } 62 | } 63 | 64 | $defaultArguments = [ 65 | 'data' => [ 66 | 'config' => [ 67 | 'dataType' => 'text', 68 | 'component' => 'Magento_Ui/js/grid/columns/column', 69 | 'componentType' => 'column', 70 | 'visible' => false, 71 | '__disableTmpl' => [ 72 | 'label' => true 73 | ] 74 | ], 75 | 'js_config' => [ 76 | 'component' => 'Magento_Ui/js/form/element/text', 77 | 'extends' => 'sales_order_grid' 78 | ] 79 | ], 80 | 'context' => $subject->getContext() 81 | ]; 82 | 83 | array_walk( 84 | $uniqueCustomFeesLabelsByCode, 85 | function (string $customFeeLabel, string $customFeeCode) use ($defaultArguments, $subject): void { 86 | $baseArguments = $defaultArguments; 87 | $baseArguments['data']['config']['label'] = __('%1 (Base)', $customFeeLabel); 88 | $baseArguments['data']['name'] = $customFeeCode . '_base'; 89 | 90 | $arguments = $defaultArguments; 91 | $arguments['data']['config']['label'] = __('%1 (Purchased)', $customFeeLabel); 92 | $arguments['data']['name'] = $customFeeCode; 93 | 94 | $baseColumn = $this->uiComponentFactory->create($customFeeCode . '_base', 'column', $baseArguments); 95 | $column = $this->uiComponentFactory->create($customFeeCode, 'column', $arguments); 96 | 97 | $subject->addComponent($customFeeCode . '_base', $baseColumn); 98 | $subject->addComponent($customFeeCode, $column); 99 | } 100 | ); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Service/CustomFeesRetriever.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | public function retrieve(Order $order): array 23 | { 24 | $orderExtension = $order->getExtensionAttributes(); 25 | 26 | if ($orderExtension === null) { 27 | return []; 28 | } 29 | 30 | /** @var array $customFees */ 31 | $customFees = $orderExtension->getCustomOrderFees() 32 | ?->getCustomFees(); 33 | 34 | if ($customFees === null) { 35 | try { 36 | /** @var int|string|null $orderId */ 37 | $orderId = $order->getEntityId(); 38 | $customFees = $this->customOrderFeesRepository->getByOrderId($orderId ?? 0) 39 | ->getCustomFees(); 40 | } catch (NoSuchEntityException) { 41 | $customFees = []; 42 | } 43 | } 44 | 45 | return $customFees; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/ViewModel/CustomFees.php: -------------------------------------------------------------------------------- 1 | config->getCustomFees(); 28 | } catch (LocalizedException) { 29 | return []; 30 | } 31 | } 32 | 33 | public function getCustomFeesAsJson(): string 34 | { 35 | return (string) ( 36 | $this->serializer->serialize($this->getCustomFees()) ?: '[]' 37 | ); 38 | } 39 | 40 | /** 41 | * @return string[] 42 | */ 43 | public function getCustomFeeCodes(): array 44 | { 45 | return array_column($this->getCustomFees(), 'code'); 46 | } 47 | 48 | public function getCustomFeeCodesAsJson(): string 49 | { 50 | return (string) ( 51 | $this->serializer->serialize($this->getCustomFeeCodes()) ?: '[]' 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/etc/acl.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/etc/adminhtml/di.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | JosephLeedy\CustomFees\Model\ResourceModel\Report\CustomOrderFees 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/etc/adminhtml/menu.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/etc/adminhtml/routes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/etc/adminhtml/system.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 | 8 | required-number validate-number 9 | 10 | 11 | 12 | 13 | 14 | 15 | JosephLeedy\CustomFees\Block\System\Config\Form\Field\CustomFees 16 | Magento\Config\Model\Config\Backend\Serialized\ArraySerialized 17 | Note: Fee amount is relative to the store's base currency and will be converted to the customer's selected currency.]]> 18 | 19 | 20 |
21 |
22 |
23 | -------------------------------------------------------------------------------- /src/etc/config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 50 7 | 8 | 9 | [{"code":"example_fee","title":"Example Fee","value":"0.00"}] 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/etc/db_schema.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |
36 |
37 | -------------------------------------------------------------------------------- /src/etc/db_schema_whitelist.json: -------------------------------------------------------------------------------- 1 | { 2 | "custom_order_fees": { 3 | "column": { 4 | "id": true, 5 | "order_entity_id": true, 6 | "custom_fees": true 7 | }, 8 | "constraint": { 9 | "PRIMARY": true, 10 | "FK_CUSTOM_ORDER_FEES_ORDER_ENTITY_ID_SALES_ORDER_ENTITY_ID": true, 11 | "UK_CUSTOM_ORDER_FEES_ORDER_ENTITY_ID": true 12 | } 13 | }, 14 | "report_custom_order_fees_aggregated": { 15 | "column": { 16 | "id": true, 17 | "period": true, 18 | "store_id": true, 19 | "fee_title": true, 20 | "base_fee_amount": true, 21 | "paid_fee_amount": true, 22 | "paid_order_currency": true, 23 | "invoiced_fee_amount": true 24 | }, 25 | "index": { 26 | "REPORT_CUSTOM_ORDER_FEES_AGGREGATED_PERIOD_STORE_ID_PAID_FEE_AMOUNT": true, 27 | "REPORT_CUSTOM_ORDER_FEES_AGGRED_PERIOD_STORE_ID_PAID_FEE_AMOUNT": true, 28 | "IDX_913FBD51E62C7A7A2399BBDE04A6D7DB": true 29 | }, 30 | "constraint": { 31 | "PRIMARY": true, 32 | "REPORT_CUSTOM_ORDER_FEES_AGGREGATED_STORE_ID_STORE_STORE_ID": true 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /src/etc/di.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/etc/events.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/etc/extension_attributes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | id 13 | order_id 14 | custom_fees 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/etc/frontend/di.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | JosephLeedy\CustomFees\Model\Checkout\ConfigProvider\CustomFees 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/etc/module.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/etc/pdf.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Custom Fees 6 | custom_fees 7 | JosephLeedy\CustomFees\Model\Sales\Pdf\CustomFees 8 | 7 9 | false 10 | 450 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/etc/sales.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 |
8 |
9 | 10 | 11 | 12 |
13 |
14 | 15 | 16 | 17 |
18 |
19 | -------------------------------------------------------------------------------- /src/i18n/en_US.csv: -------------------------------------------------------------------------------- 1 | "Total Custom Order Fees Report","Total Custom Order Fees Report" 2 | "Show Report","Show Report" 3 | No,No 4 | Yes,Yes 5 | "Show Base Amount","Show Base Amount" 6 | Interval,Interval 7 | Total,Total 8 | Title,Title 9 | Amount,Amount 10 | "Amount Paid","Amount Paid" 11 | "Amount Invoiced","Amount Invoiced" 12 | CSV,CSV 13 | "Excel XML","Excel XML" 14 | "Fee Amount","Fee Amount" 15 | Code,Code 16 | "Fee Name","Fee Name" 17 | "Add Custom Fee","Add Custom Fee" 18 | Sales,Sales 19 | "Custom Order Fees Report","Custom Order Fees Report" 20 | "This report requires MySQL 8.0.4 or greater, MariaDB 10.6.0 or greater OR a MySQL 8.0-compatible database server to generate properly. Your database server version ""%1"" is not compatible.","This report requires MySQL 8.0.4 or greater, MariaDB 10.6.0 or greater OR a MySQL 8.0-compatible database server to generate properly. Your database server version ""%1"" is not compatible." 21 | "Could not get custom fees from configuration. Error: ""%1""","Could not get custom fees from configuration. Error: ""%1""" 22 | "Invalid custom fees","Invalid custom fees" 23 | "Custom order fees with ID ""%1"" does not exist.","Custom order fees with ID ""%1"" does not exist." 24 | "Custom fees do not exist for order with ID ""%1"".","Custom fees do not exist for order with ID ""%1""." 25 | "Custom fees have already been saved for order with ID ""%1"".","Custom fees have already been saved for order with ID ""%1""." 26 | "Could not save custom fees for order with ID ""%1"". Error: ""%2""","Could not save custom fees for order with ID ""%1"". Error: ""%2""" 27 | "Could not delete custom fees for order with ID ""%1"". Error: ""%2""","Could not delete custom fees for order with ID ""%1"". Error: ""%2""" 28 | "Order with ID ""%1"" does not exist.","Order with ID ""%1"" does not exist." 29 | "Unsupported database server version ""%1"".","Unsupported database server version ""%1""." 30 | "Custom Fees","Custom Fees" 31 | "Custom Order Fees","Custom Order Fees" 32 | "%1 (Base)","%1 (Base)" 33 | "%1 (Purchased)","%1 (Purchased)" 34 | "Note: Fee amount is relative to the store's base currency and will be converted to the customer's selected currency.","Note: Fee amount is relative to the store's base currency and will be converted to the customer's selected currency." 35 | "All Websites","All Websites" 36 | -------------------------------------------------------------------------------- /src/registration.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | store_ids 8 | 9 | 10 | 1 11 | 1 12 | 1 13 | All Websites 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/view/adminhtml/layout/sales_order_creditmemo_new.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/view/adminhtml/layout/sales_order_creditmemo_updateqty.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/view/adminhtml/layout/sales_order_creditmemo_view.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/view/adminhtml/layout/sales_order_invoice_new.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/view/adminhtml/layout/sales_order_invoice_updateqty.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/view/adminhtml/layout/sales_order_invoice_view.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/view/adminhtml/layout/sales_order_view.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/view/frontend/layout/checkout_cart_index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | JosephLeedy_CustomFees/js/view/cart/totals/custom_fees 12 | 15 13 | 14 | Custom Fees 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/view/frontend/layout/checkout_index_index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | JosephLeedy_CustomFees/js/view/cart/totals/custom_fees 18 | 15 19 | 20 | Custom Fees 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/view/frontend/layout/hyva_checkout_cart_index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/view/frontend/layout/hyva_checkout_components.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/view/frontend/layout/sales_email_order_creditmemo_items.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/view/frontend/layout/sales_email_order_invoice_items.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/view/frontend/layout/sales_email_order_items.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/view/frontend/layout/sales_guest_creditmemo.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/view/frontend/layout/sales_guest_invoice.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/view/frontend/layout/sales_guest_print.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/view/frontend/layout/sales_guest_printcreditmemo.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/view/frontend/layout/sales_guest_printinvoice.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/view/frontend/layout/sales_guest_view.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/view/frontend/layout/sales_order_creditmemo.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/view/frontend/layout/sales_order_invoice.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/view/frontend/layout/sales_order_print.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/view/frontend/layout/sales_order_printcreditmemo.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/view/frontend/layout/sales_order_printinvoice.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/view/frontend/layout/sales_order_view.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/view/frontend/templates/hyva/checkout/price-summary/total-segments/custom_fees.phtml: -------------------------------------------------------------------------------- 1 | getSegment(); 18 | $customFeesSegmentExtension = $customFeesSegment['extension_attributes'] ?? null; 19 | /** @var FormatterViewModel $formatterViewModel */ 20 | $formatterViewModel = $viewModels->require(FormatterViewModel::class); 21 | 22 | if ($customFeesSegmentExtension === null || $customFeesSegmentExtension->getCustomFeeSegments() === null): 23 | return; 24 | endif; 25 | ?> 26 | getCustomFeeSegments() as $customFeeSegment): 29 | if ($customFeeSegment->getValue() === 0): 30 | continue; 31 | endif; 32 | // phpcs:ignore Generic.WhiteSpace.ScopeIndent.IncorrectExact 33 | ?> 34 |
35 | 36 | escapeHtml($customFeeSegment->getTitle() ?? '') ?> 37 | 38 | 39 | currency($customFeeSegment->getValue()) ?> 40 | 41 |
42 | 43 | -------------------------------------------------------------------------------- /src/view/frontend/templates/hyva/php-cart/totals.phtml: -------------------------------------------------------------------------------- 1 | require(CustomFees::class); 14 | ?> 15 | 40 | registerInlineScript() ?> 41 | -------------------------------------------------------------------------------- /src/view/frontend/templates/hyva/php-cart/totals/custom_fees-csp.phtml: -------------------------------------------------------------------------------- 1 |
2 | 8 |
9 | -------------------------------------------------------------------------------- /src/view/frontend/templates/hyva/php-cart/totals/custom_fees.phtml: -------------------------------------------------------------------------------- 1 | require(CustomFeesViewModel::class); 12 | ?> 13 | 32 | 38 | -------------------------------------------------------------------------------- /src/view/frontend/templates/hyva/php-cart/totals/js/custom_fees-js.phtml: -------------------------------------------------------------------------------- 1 | require(CustomFeesViewModel::class); 14 | 15 | /** 16 | * The AlpineJS scope of this file is the method `initCartTotals()` in Magento_Checkout::php-cart/totals.phtml 17 | */ 18 | ?> 19 | 58 | registerInlineScript() ?> 59 | -------------------------------------------------------------------------------- /src/view/frontend/web/js/view/cart/totals/custom_fees.js: -------------------------------------------------------------------------------- 1 | define( 2 | [ 3 | 'JosephLeedy_CustomFees/js/view/checkout/summary/custom_fees' 4 | ], 5 | function (Component) { 6 | 'use strict'; 7 | 8 | return Component.extend( 9 | { 10 | /** 11 | * @override 12 | */ 13 | isFullMode: function () { 14 | return true; 15 | } 16 | } 17 | ); 18 | } 19 | ); 20 | -------------------------------------------------------------------------------- /src/view/frontend/web/js/view/checkout/summary/custom_fees.js: -------------------------------------------------------------------------------- 1 | define( 2 | [ 3 | 'Magento_Checkout/js/view/summary/abstract-total', 4 | 'Magento_Checkout/js/model/quote', 5 | 'Magento_Checkout/js/model/totals', 6 | 'Magento_Catalog/js/price-utils' 7 | ], 8 | function (Component, quote, totals) { 9 | 'use strict'; 10 | 11 | return Component.extend( 12 | { 13 | defaults: { 14 | template: 'JosephLeedy_CustomFees/checkout/summary/custom_fees' 15 | }, 16 | totals: totals.totals, 17 | 18 | /** 19 | * @returns {Array} 20 | */ 21 | getCustomFees: function () { 22 | const self = this; 23 | const customFeeCodes = window.checkoutConfig.customFees?.codes ?? []; 24 | const customFees = []; 25 | 26 | customFeeCodes.forEach( 27 | function (customFeeCode) { 28 | const customFee = totals.getSegment(customFeeCode); 29 | 30 | if (customFee === null || !customFee.hasOwnProperty('value') || customFee.value === 0) { 31 | return; 32 | } 33 | 34 | customFee.formattedPrice = self.getFormattedPrice(customFee.value); 35 | 36 | customFees.push(customFee); 37 | } 38 | ); 39 | 40 | return customFees; 41 | }, 42 | 43 | /** 44 | * @returns {Boolean} 45 | */ 46 | isDisplayed: function () { 47 | return this.isFullMode() && this.getCustomFees().length > 0; 48 | } 49 | } 50 | ); 51 | } 52 | ); 53 | -------------------------------------------------------------------------------- /src/view/frontend/web/template/checkout/summary/custom_fees.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /test/Integration/Block/Adminhtml/CustomOrderFees/Report/GridTest.php: -------------------------------------------------------------------------------- 1 | create(Grid::class); 27 | /** @var LayoutInterface $layout */ 28 | $layout = $objectManger->get(LayoutInterface::class); 29 | /** @var DataObject $filterData */ 30 | $filterData = $objectManger->create( 31 | DataObject::class, 32 | [ 33 | 'data' => [ 34 | 'show_base_amount' => 1, 35 | ], 36 | ], 37 | ); 38 | 39 | $layout->addBlock($grid, 'custom_order_fees.report.grid'); 40 | 41 | $grid->setFilterData($filterData); 42 | $grid->toHtml(); 43 | 44 | $expectedColumns = [ 45 | 'period', 46 | 'fee_title', 47 | 'base_fee_amount', 48 | 'paid_fee_amount', 49 | 'invoiced_fee_amount', 50 | ]; 51 | $actualColumns = array_keys($grid->getColumns()); 52 | $expectedExportTypes = [ 53 | 'CSV', 54 | 'Excel XML', 55 | ]; 56 | $actualExportTypes = array_map( 57 | static fn(DataObject $exportType): string => $exportType->getLabel(), 58 | $grid->getExportTypes() ?: [], 59 | ); 60 | 61 | self::assertSame($expectedColumns, $actualColumns); 62 | self::assertEquals($expectedExportTypes, $actualExportTypes); 63 | } 64 | 65 | #[DataFixture('JosephLeedy_CustomFees::../test/Integration/_files/aggregated_custom_order_fees.php')] 66 | public function testSetsTotalsWithMultipleCurrencies(): void 67 | { 68 | $objectManger = Bootstrap::getObjectManager(); 69 | /** @var Grid $grid */ 70 | $grid = $objectManger->create(Grid::class); 71 | /** @var LayoutInterface $layout */ 72 | $layout = $objectManger->get(LayoutInterface::class); 73 | /** @var DataObject $filterData */ 74 | $filterData = $objectManger->create( 75 | DataObject::class, 76 | [ 77 | 'data' => [ 78 | 'show_base_amount' => 1, 79 | 'period_type' => 'day', 80 | 'from' => '2025-01-01', 81 | 'to' => '2025-12-31', 82 | ], 83 | ], 84 | ); 85 | 86 | $layout->addBlock($grid, 'custom_order_fees.report.grid'); 87 | 88 | $grid->setFilterData($filterData); 89 | $grid->toHtml(); 90 | 91 | $totals = $grid->getTotals(); 92 | $expectedTotalData = [ 93 | 'base_fee_amount' => '19.5', 94 | 'paid_fee_amount' => '6.5000, 9.1872', 95 | 'invoiced_fee_amount' => '0.0000, 0.0000', 96 | 'paid_order_currency' => 'USD, EUR', 97 | 'orig_data' => null, 98 | ]; 99 | $actualTotalData = $totals->getData(); 100 | 101 | self::assertSame($expectedTotalData, $actualTotalData); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /test/Integration/Block/Sales/Order/TotalsTest.php: -------------------------------------------------------------------------------- 1 | setCurrentFixtureType(DataFixture::ANNOTATION); 41 | $resolver->requireDataFixture("JosephLeedy_CustomFees::../test/Integration/_files/$filename.php"); 42 | 43 | /** @var ObjectManagerInterface $objectManager */ 44 | $objectManager = Bootstrap::getObjectManager(); 45 | /** @var Order $order */ 46 | $order = $objectManager->create(Order::class); 47 | /** @var OrderTotalsBlock|InvoiceTotalsBlock|CreditmemoTotalsBlock $totalsBlock */ 48 | $totalsBlock = match ($totalsType) { 49 | 'order' => $objectManager->create(OrderTotalsBlock::class), 50 | 'invoice' => $objectManager->create(InvoiceTotalsBlock::class), 51 | 'creditmemo' => $objectManager->create(CreditmemoTotalsBlock::class), 52 | }; 53 | $customOrderFeesTotalsBlock = $this->getMockBuilder(CustomOrderFeesTotalsBlock::class) 54 | ->setConstructorArgs( 55 | [ 56 | 'context' => $objectManager->get(Context::class), 57 | 'customFeesRetriever' => $objectManager->create(CustomFeesRetriever::class), 58 | 'dataObjectFactory' => $objectManager->get(DataObjectFactory::class), 59 | 'data' => [] 60 | ] 61 | )->onlyMethods( 62 | [ 63 | 'getParentBlock', 64 | ] 65 | )->getMock(); 66 | 67 | $order->loadByIncrementId('100000001'); 68 | 69 | $customOrderFeesTotalsBlock->method('getParentBlock') 70 | ->willReturn($totalsBlock); 71 | 72 | $totalsBlock->setOrder($order); 73 | 74 | if ($totalsType === 'invoice') { 75 | $totalsBlock->setInvoice($order->getInvoiceCollection()->getFirstItem()); 76 | } 77 | 78 | if ($totalsType === 'creditmemo') { 79 | $totalsBlock->setCreditmemo($order->getCreditmemosCollection()->getFirstItem()); 80 | } 81 | 82 | $totalsBlock->toHtml(); 83 | 84 | $customOrderFeesTotalsBlock->initTotals(); 85 | 86 | $orderTotals = $totalsBlock->getTotals(); 87 | 88 | self::$assertion('test_fee_0', $orderTotals); 89 | self::$assertion('test_fee_1', $orderTotals); 90 | } 91 | 92 | /** 93 | * @return string[][] 94 | */ 95 | public function initTotalsDataProvider(): array 96 | { 97 | return [ 98 | 'does initialize totals for order with custom fees' => [ 99 | 'totalsType' => 'order', 100 | 'condition' => 'does' 101 | ], 102 | 'does initialize totals for invoice with custom fees' => [ 103 | 'totalsType' => 'invoice', 104 | 'condition' => 'does' 105 | ], 106 | 'does initialize totals for creditmemo with custom fees' => [ 107 | 'totalsType' => 'creditmemo', 108 | 'condition' => 'does' 109 | ], 110 | 'does not initialize totals for order without custom fees' => [ 111 | 'totalsType' => 'order', 112 | 'condition' => 'does not' 113 | ], 114 | 'does not initialize totals for invoice without custom fees' => [ 115 | 'totalsType' => 'invoice', 116 | 'condition' => 'does not' 117 | ], 118 | 'does not initialize totals for creditmemo without custom fees' => [ 119 | 'totalsType' => 'creditmemo', 120 | 'condition' => 'does not' 121 | ] 122 | ]; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /test/Integration/Controller/Adminhtml/Report/CustomOrderFees/Export/CsvTest.php: -------------------------------------------------------------------------------- 1 | dispatch($this->uri); 22 | 23 | /** @var HttpResponse $response */ 24 | $response = $this->getResponse(); 25 | 26 | self::assertEquals(302, $response->getHttpResponseCode()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/Integration/Controller/Adminhtml/Report/CustomOrderFees/Export/ExcelTest.php: -------------------------------------------------------------------------------- 1 | dispatch($this->uri); 22 | 23 | /** @var HttpResponse $response */ 24 | $response = $this->getResponse(); 25 | 26 | self::assertEquals(302, $response->getHttpResponseCode()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/Integration/Controller/Adminhtml/Report/CustomOrderFeesTest.php: -------------------------------------------------------------------------------- 1 | dispatch($this->uri); 22 | 23 | /** @var HttpResponse $response */ 24 | $response = $this->getResponse(); 25 | 26 | self::assertEquals(200, $response->getHttpResponseCode()); 27 | self::assertStringContainsString('Custom Order Fees Report', $response->getBody()); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/Integration/ExtensionAttributesTest.php: -------------------------------------------------------------------------------- 1 | create(SearchCriteriaBuilder::class); 22 | $searchCriteria = $searchCriteriaBuilder->addFilter( 23 | 'increment_id', 24 | [ 25 | '100000001', 26 | '100000002', 27 | '100000003', 28 | '100000004' 29 | ], 30 | 'in' 31 | )->create(); 32 | /** @var OrderRepositoryInterface $orderRepository */ 33 | $orderRepository = $objectManager->create(OrderRepositoryInterface::class); 34 | 35 | $expectedCustomOrderFees = [ 36 | '_1727299122629_629' => [ 37 | 'code' => 'test_fee_0', 38 | 'title' => 'Test Fee', 39 | 'base_value' => 5.00, 40 | 'value' => 5.00 41 | ], 42 | '_1727299257083_083' => [ 43 | 'code' => 'test_fee_1', 44 | 'title' => 'Another Test Fee', 45 | 'base_value' => 1.50, 46 | 'value' => 1.50 47 | ] 48 | ]; 49 | $searchResults = $orderRepository->getList($searchCriteria); 50 | 51 | foreach ($searchResults->getItems() as $order) { 52 | $actualCustomOrderFees = $order->getExtensionAttributes() 53 | ?->getCustomOrderFees() 54 | ?->getCustomFees(); 55 | 56 | self::assertNotNull($actualCustomOrderFees); 57 | self::assertEquals($expectedCustomOrderFees, $actualCustomOrderFees); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /test/Integration/Model/Checkout/ConfigProvider/CustomFeesTest.php: -------------------------------------------------------------------------------- 1 | createMock(DefaultConfigProvider::class); 25 | /* The above mock is a work-around for a bug in `\Magento\Checkout\Model\DefaultConfigProvider::getConfig()` 26 | which does not check for a valid quote prior to executing its logic. */ 27 | 28 | $objectManager->addSharedInstance($defaultConfigProviderMock, DefaultConfigProvider::class); 29 | 30 | /** @var CompositeConfigProvider $compositeConfigProvider */ 31 | $compositeConfigProvider = $objectManager->create(CompositeConfigProvider::class); 32 | 33 | $defaultConfigProviderMock->method('getConfig') 34 | ->willReturn([]); 35 | 36 | $providedConfig = $compositeConfigProvider->getConfig(); 37 | 38 | self::assertArrayHasKey('customFees', $providedConfig); 39 | self::assertEquals( 40 | [ 41 | 'codes' => [ 42 | 'test_fee_0', 43 | 'test_fee_1' 44 | ] 45 | ], 46 | $providedConfig['customFees'] 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /test/Integration/Model/ResourceModel/Report/CustomOrderFees/CollectionTest.php: -------------------------------------------------------------------------------- 1 | $aggregatedColumns 22 | * @param array $expectedColumns 23 | * @param array $expectedGroups 24 | */ 25 | public function testSelectsAndGroupsCorrectFields( 26 | array $aggregatedColumns, 27 | array $expectedColumns, 28 | array $expectedGroups, 29 | ): void { 30 | $objectManager = Bootstrap::getObjectManager(); 31 | /** @var Collection $collection */ 32 | $collection = $objectManager->create(Collection::class); 33 | 34 | if (count($aggregatedColumns) > 0) { 35 | $collection->setAggregatedColumns($aggregatedColumns); 36 | $collection->isTotals(true); 37 | } 38 | 39 | $collection->load(); 40 | 41 | $select = $collection->getSelect(); 42 | $actualColumns = array_column((array) $select->getPart('columns'), 2); 43 | $actualGroups = $select->getPart('group'); 44 | 45 | self::assertSame($expectedColumns, $actualColumns); 46 | self::assertEquals($expectedGroups, $actualGroups); 47 | } 48 | 49 | /** 50 | * @return array>> 51 | */ 52 | public static function selectsAndGroupsCorrectFieldsDataProvider(): array 53 | { 54 | return [ 55 | 'without totals' => [ 56 | 'aggregatedColumns' => [], 57 | 'expectedColumns' => [ 58 | 'period', 59 | 'fee_title', 60 | 'base_fee_amount', 61 | 'paid_fee_amount', 62 | 'paid_order_currency', 63 | 'invoiced_fee_amount', 64 | ], 65 | 'expectedGroups' => [ 66 | new Zend_Db_Expr('DATE_FORMAT(period, \'%Y-%m-%d\')'), 67 | 'fee_title', 68 | 'paid_order_currency', 69 | ], 70 | ], 71 | 'with totals' => [ 72 | 'aggregatedColumns' => [ 73 | 'base_fee_amount' => 'sum(base_fee_amount)', 74 | 'paid_fee_amount' => 'sum(paid_fee_amount)', 75 | 'paid_order_currency' => 'paid_order_currency', 76 | 'invoiced_fee_amount' => 'sum(invoiced_fee_amount)', 77 | ], 78 | 'expectedColumns' => [ 79 | 'base_fee_amount', 80 | 'paid_fee_amount', 81 | 'paid_order_currency', 82 | 'invoiced_fee_amount', 83 | ], 84 | 'expectedGroups' => [ 85 | 'paid_order_currency', 86 | ], 87 | ], 88 | ]; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /test/Integration/Model/ResourceModel/Report/CustomOrderFeesTest.php: -------------------------------------------------------------------------------- 1 | create(CustomOrderFees::class); 27 | 28 | $customOrderFeesReport->aggregate(); 29 | 30 | /** @var AdapterInterface $connection */ 31 | $connection = $customOrderFeesReport->getConnection(); 32 | $query = $connection->select()->from(CustomOrderFees::TABLE_NAME); 33 | $aggregatedCustomOrderFees = $connection->fetchAll($query); 34 | 35 | self::assertNotEmpty($aggregatedCustomOrderFees); 36 | } 37 | 38 | public function testThrowsExceptionIfDatabaseServerVersionIsUnsupported(): void 39 | { 40 | $this->expectException(LocalizedException::class); 41 | $this->expectExceptionMessage('Unsupported database server version "10.4.34-MariaDB".'); 42 | 43 | $connectionStub = $this->createStub(AdapterInterface::class); 44 | $resourcesStub = $this->createStub(ResourceConnection::class); 45 | $contextStub = $this->createStub(Context::class); 46 | 47 | $connectionStub 48 | ->method('fetchOne') 49 | ->willReturn('10.4.34-MariaDB'); 50 | 51 | $resourcesStub 52 | ->method('getConnection') 53 | ->willReturn($connectionStub); 54 | 55 | $contextStub 56 | ->method('getResources') 57 | ->willReturn($resourcesStub); 58 | 59 | $objectManager = Bootstrap::getObjectManager(); 60 | /** @var CustomOrderFees $customOrderFeesReport */ 61 | $customOrderFeesReport = $objectManager->create( 62 | CustomOrderFees::class, 63 | [ 64 | 'context' => $contextStub, 65 | ] 66 | ); 67 | 68 | $customOrderFeesReport->aggregate(); 69 | } 70 | 71 | /** 72 | * @dataProvider checksIfDatabaseServerVersionIsSupportedDataProvider 73 | */ 74 | public function testChecksIfDatabaseServerVersionIsSupported( 75 | string $databaseServerVersion, 76 | bool $expectedResult 77 | ): void { 78 | $connectionStub = $this->createStub(AdapterInterface::class); 79 | $resourcesStub = $this->createStub(ResourceConnection::class); 80 | $contextStub = $this->createStub(Context::class); 81 | 82 | $connectionStub 83 | ->method('fetchOne') 84 | ->willReturn($databaseServerVersion); 85 | 86 | $resourcesStub 87 | ->method('getConnection') 88 | ->willReturn($connectionStub); 89 | 90 | $contextStub 91 | ->method('getResources') 92 | ->willReturn($resourcesStub); 93 | 94 | $objectManager = Bootstrap::getObjectManager(); 95 | /** @var CustomOrderFees $customOrderFeesReport */ 96 | $customOrderFeesReport = $objectManager->create( 97 | CustomOrderFees::class, 98 | [ 99 | 'context' => $contextStub, 100 | ] 101 | ); 102 | 103 | $actualResult = $customOrderFeesReport->isDatabaseServerSupported($databaseServerVersion); 104 | 105 | self::assertSame($expectedResult, $actualResult); 106 | } 107 | 108 | /** 109 | * @return array> 110 | */ 111 | public static function checksIfDatabaseServerVersionIsSupportedDataProvider(): array 112 | { 113 | return [ 114 | 'MySQL 8.0 is supported' => [ 115 | 'databaseServerVersion' => '8.0.43', 116 | 'expectedResult' => true, 117 | ], 118 | 'MySQL 5.7 is unsupported' => [ 119 | 'databaseServerVersion' => '5.7.44', 120 | 'expectedResult' => false, 121 | ], 122 | 'MariaDB 10.6 is supported' => [ 123 | 'databaseServerVersion' => '10.6.21-MariaDB', 124 | 'expectedResult' => true, 125 | ], 126 | 'MariaDB 10.4 is unsupported' => [ 127 | 'databaseServerVersion' => '10.4.34-MariaDB', 128 | 'expectedResult' => false, 129 | ], 130 | 'AWS Aurora MySQL 3.04 is supported' => [ 131 | 'databaseServerVersion' => '8.0.mysql_aurora.3.04.0', 132 | 'expectedResult' => true, 133 | ], 134 | 'AWS Aurora MySQL 2.11 is unsupported' => [ 135 | 'databaseServerVersion' => '5.7.mysql_aurora.2.11.2', 136 | 'expectedResult' => false, 137 | ], 138 | 'Percona MySQL 8.0 is supported' => [ 139 | 'databaseServerVersion' => '8.0.41-32', 140 | 'expectedResult' => true, 141 | ], 142 | 'Percona MySQL 5.7 is unsupported' => [ 143 | 'databaseServerVersion' => '5.7.10-3', 144 | 'expectedResult' => false, 145 | ], 146 | ]; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /test/Integration/Model/Total/Creditmemo/CustomFeesTest.php: -------------------------------------------------------------------------------- 1 | create(Order::class); 26 | /** @var OrderResource $orderResource */ 27 | $orderResource = $objectManager->create(OrderResource::class); 28 | 29 | $orderResource->load($order, '100000001', 'increment_id'); 30 | 31 | /** @var CreditmemoCollection $creditmemosCollection */ 32 | $creditmemosCollection = $order->getCreditmemosCollection() 33 | ?: $objectManager->create(CreditmemoCollection::class); 34 | 35 | /** @var Creditmemo $creditmemo */ 36 | $creditmemo = $creditmemosCollection->getFirstItem(); 37 | 38 | self::assertEquals(26.50, $creditmemo->getBaseGrandTotal()); 39 | self::assertEquals(26.50, $creditmemo->getGrandTotal()); 40 | } 41 | 42 | /** 43 | * @magentoDataFixture JosephLeedy_CustomFees::../test/Integration/_files/creditmemo.php 44 | */ 45 | public function testDoesNotCollectsCustomFeesTotals(): void 46 | { 47 | /** @var ObjectManagerInterface $objectManager */ 48 | $objectManager = Bootstrap::getObjectManager(); 49 | /** @var Order $order */ 50 | $order = $objectManager->create(Order::class); 51 | /** @var OrderResource $orderResource */ 52 | $orderResource = $objectManager->create(OrderResource::class); 53 | 54 | $orderResource->load($order, '100000001', 'increment_id'); 55 | 56 | /** @var CreditmemoCollection $creditmemosCollection */ 57 | $creditmemosCollection = $order->getCreditmemosCollection() 58 | ?: $objectManager->create(CreditmemoCollection::class); 59 | 60 | /** @var Creditmemo $creditmemo */ 61 | $creditmemo = $creditmemosCollection->getFirstItem(); 62 | 63 | self::assertEquals(20, $creditmemo->getGrandTotal()); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /test/Integration/Model/Total/Invoice/CustomFeesTest.php: -------------------------------------------------------------------------------- 1 | create(Order::class); 25 | /** @var OrderResource $orderResource */ 26 | $orderResource = $objectManager->create(OrderResource::class); 27 | 28 | $orderResource->load($order, '100000001', 'increment_id'); 29 | 30 | /** @var Invoice $invoice */ 31 | $invoice = $order->getInvoiceCollection()->getFirstItem(); 32 | 33 | self::assertEquals(26.50, $invoice->getGrandTotal()); 34 | } 35 | 36 | /** 37 | * @magentoDataFixture JosephLeedy_CustomFees::../test/Integration/_files/invoice.php 38 | */ 39 | public function testDoesNotCollectsCustomFeesTotals(): void 40 | { 41 | /** @var ObjectManagerInterface $objectManager */ 42 | $objectManager = Bootstrap::getObjectManager(); 43 | /** @var Order $order */ 44 | $order = $objectManager->create(Order::class); 45 | /** @var OrderResource $orderResource */ 46 | $orderResource = $objectManager->create(OrderResource::class); 47 | 48 | $orderResource->load($order, '100000001', 'increment_id'); 49 | 50 | /** @var Invoice $invoice */ 51 | $invoice = $order->getInvoiceCollection()->getFirstItem(); 52 | 53 | self::assertEquals(20, $invoice->getGrandTotal()); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /test/Integration/Model/Total/Quote/CustomFeesTest.php: -------------------------------------------------------------------------------- 1 | create(Quote::class); 33 | /** @var QuoteResource $quoteResource */ 34 | $quoteResource = $objectManager->create(QuoteResource::class); 35 | 36 | $quoteResource->load($quote, 'test_order_1', 'reserved_order_id'); 37 | 38 | $quote->collectTotals(); 39 | 40 | $collectedTotals = $quote->getTotals(); 41 | 42 | self::assertArrayHasKey('test_fee_0', $collectedTotals); 43 | self::assertArrayHasKey('test_fee_1', $collectedTotals); 44 | self::assertEquals( 45 | [ 46 | 'code' => 'test_fee_0', 47 | 'title' => __('Test Fee'), 48 | 'value' => 4.00 49 | ], 50 | $collectedTotals['test_fee_0']->getData() 51 | ); 52 | self::assertEquals( 53 | [ 54 | 'code' => 'test_fee_1', 55 | 'title' => __('Another Fee'), 56 | 'value' => 1.00 57 | ], 58 | $collectedTotals['test_fee_1']->getData() 59 | ); 60 | self::assertNotNull($quote->getExtensionAttributes()?->getCustomFees()); 61 | self::assertEquals( 62 | [ 63 | [ 64 | 'code' => 'test_fee_0', 65 | 'title' => __('Test Fee'), 66 | 'base_value' => 4.00, 67 | 'value' => 4.00 68 | ], 69 | [ 70 | 'code' => 'test_fee_1', 71 | 'title' => __('Another Fee'), 72 | 'base_value' => 1.00, 73 | 'value' => 1.00 74 | ] 75 | ], 76 | $quote->getExtensionAttributes()->getCustomFees() 77 | ); 78 | } 79 | 80 | /** 81 | * @phpcs:ignore Generic.Files.LineLength.TooLong 82 | * @magentoConfigFixture current_store sales/custom_order_fees/custom_fees [{"code":"test_fee_0","title":"Test Fee","value":"4.00"},{"code":"test_fee_1","title":"Another Fee","value":"1.00"}] 83 | * @magentoDataFixture Magento/Checkout/_files/quote_with_address.php 84 | */ 85 | public function testFetchesCustomFeesTotals(): void 86 | { 87 | $objectManager = Bootstrap::getObjectManager(); 88 | /** @var Quote $quote */ 89 | $quote = $objectManager->create(Quote::class); 90 | /** @var QuoteResource $quoteResource */ 91 | $quoteResource = $objectManager->create(QuoteResource::class); 92 | /** @var Total $total */ 93 | $total = $objectManager->create(Total::class); 94 | /** @var CustomFees $customFeesTotalCollector */ 95 | $customFeesTotalCollector = $objectManager->create(CustomFees::class); 96 | 97 | $quoteResource->load($quote, 'test_order_1', 'reserved_order_id'); 98 | 99 | $expectedCustomFees = [ 100 | [ 101 | 'code' => 'test_fee_0', 102 | 'title' => __('Test Fee'), 103 | 'value' => 4.00 104 | ], 105 | [ 106 | 'code' => 'test_fee_1', 107 | 'title' => __('Another Fee'), 108 | 'value' => 1.00 109 | ] 110 | ]; 111 | $actualCustomFees = $customFeesTotalCollector->fetch($quote, $total); 112 | 113 | self::assertEquals($expectedCustomFees, $actualCustomFees); 114 | } 115 | 116 | /** 117 | * @magentoDataFixture Magento/Checkout/_files/quote_with_address.php 118 | */ 119 | public function testDoesNotCollectExampleCustomFeesTotals(): void 120 | { 121 | /** @var ObjectManagerInterface $objectManager */ 122 | $objectManager = Bootstrap::getObjectManager(); 123 | /** @var ConfigInterface $config */ 124 | $config = $objectManager->get(ConfigInterface::class); 125 | /** @var Quote $quote */ 126 | $quote = $objectManager->create(Quote::class); 127 | /** @var QuoteResource $quoteResource */ 128 | $quoteResource = $objectManager->create(QuoteResource::class); 129 | 130 | try { 131 | $customFees = $config->getCustomFees(); 132 | } catch (LocalizedException) { 133 | $customFees = []; 134 | } 135 | 136 | if (count($customFees) === 0 || !in_array('example_fee', array_column($customFees, 'code'), true)) { 137 | self::fail('Example custom fee is not configured'); 138 | } 139 | 140 | $quoteResource->load($quote, 'test_order_1', 'reserved_order_id'); 141 | 142 | $quote->collectTotals(); 143 | 144 | $collectedTotals = $quote->getTotals(); 145 | 146 | self::assertArrayNotHasKey('example_fee', $collectedTotals); 147 | self::assertEmpty($quote->getExtensionAttributes()?->getCustomFees()); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /test/Integration/Observer/BeforeQuoteSubmitObserverTest.php: -------------------------------------------------------------------------------- 1 | create(EventObserverConfig::class); 28 | $observers = $observerConfig->getObservers('sales_model_service_quote_submit_before'); 29 | 30 | self::assertArrayHasKey('add_custom_fees_to_order', $observers); 31 | self::assertSame( 32 | ltrim(BeforeQuoteSubmitObserver::class, '\\'), 33 | $observers['add_custom_fees_to_order']['instance'] 34 | ); 35 | } 36 | 37 | /** 38 | * @phpcs:ignore Generic.Files.LineLength.TooLong 39 | * @magentoConfigFixture current_store sales/custom_order_fees/custom_fees [{"code":"test_fee_0","title":"Test Fee","value":"4.00"},{"code":"test_fee_1","title":"Another Fee","value":"1.00"}] 40 | * @magentoDataFixture Magento/Checkout/_files/quote_with_shipping_method.php 41 | */ 42 | public function testAddsCustomFeesToOrder(): void 43 | { 44 | $objectManager = Bootstrap::getObjectManager(); 45 | /** @var Quote $quote */ 46 | $quote = $objectManager->create(Quote::class); 47 | /** @var QuoteResource $quoteResource */ 48 | $quoteResource = $objectManager->create(QuoteResource::class); 49 | /** @var Order $order */ 50 | $order = $objectManager->create(Order::class); 51 | /** @var OrderExtension $orderExtension */ 52 | $orderExtension = $objectManager->create(OrderExtension::class); 53 | /** @var EventManager $eventManager */ 54 | $eventManager = $objectManager->create(EventManager::class); 55 | 56 | $quoteResource->load($quote, 'test_order_1', 'reserved_order_id'); 57 | 58 | $quote->collectTotals(); 59 | 60 | $order->setExtensionAttributes($orderExtension); 61 | 62 | $eventManager->dispatch( 63 | 'sales_model_service_quote_submit_before', 64 | [ 65 | 'quote' => $quote, 66 | 'order' => $order 67 | ] 68 | ); 69 | 70 | self::assertInstanceOf( 71 | CustomOrderFeesInterface::class, 72 | $order->getExtensionAttributes()?->getCustomOrderFees() 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /test/Integration/Plugin/Framework/View/Element/UiComponent/DataProvider/CollectionFactoryPluginTest.php: -------------------------------------------------------------------------------- 1 | create(PluginList::class); 25 | /** 26 | * @var array{add_custom_fees_to_order_grid_items: array{sortOrder: int, instance: class-string}} $plugins 27 | */ 28 | $plugins = $pluginList->get(CollectionFactory::class, []); 29 | 30 | self::assertArrayHasKey('add_custom_fees_to_order_grid_items', $plugins); 31 | self::assertSame(CollectionFactoryPlugin::class, $plugins['add_custom_fees_to_order_grid_items']['instance']); 32 | } 33 | 34 | #[DataFixture('JosephLeedy_CustomFees::../test/Integration/_files/orders_with_custom_fees.php')] 35 | public function testAddsCustomFeesToOrderGridCollection(): void 36 | { 37 | $objectManager = Bootstrap::getObjectManager(); 38 | /** @var CollectionFactory $collectionFactory */ 39 | $collectionFactory = $objectManager->create( 40 | CollectionFactory::class, 41 | [ 42 | 'collections' => [ 43 | 'sales_order_grid_data_source' => OrderGridCollection::class, 44 | ], 45 | ] 46 | ); 47 | 48 | $report = $collectionFactory->getReport('sales_order_grid_data_source'); 49 | $firstItem = $report->getFirstItem(); 50 | 51 | self::assertArrayHasKey('custom_fees', (array) $firstItem->getData()); 52 | } 53 | 54 | public function testDoesNotAddCustomFeesToOrderGridCollection(): void 55 | { 56 | $objectManager = Bootstrap::getObjectManager(); 57 | /** @var CollectionFactory $collectionFactory */ 58 | $collectionFactory = $objectManager->create( 59 | CollectionFactory::class, 60 | [ 61 | 'collections' => [ 62 | 'product_grid_data_source' => ProductCollection::class, 63 | ], 64 | ] 65 | ); 66 | 67 | $report = $collectionFactory->getReport('product_grid_data_source'); 68 | $firstItem = $report->getFirstItem(); 69 | 70 | self::assertArrayNotHasKey('custom_fees', (array) $firstItem->getData()); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /test/Integration/Plugin/Framework/View/Element/UiComponent/DataProvider/DataProviderPluginTest.php: -------------------------------------------------------------------------------- 1 | create(PluginList::class); 23 | /** 24 | * @var array{process_custom_order_fees: array{sortOrder: int, instance: class-string}} $plugins 25 | */ 26 | $plugins = $pluginList->get(DataProvider::class, []); 27 | 28 | self::assertArrayHasKey('process_custom_order_fees', $plugins); 29 | self::assertSame(DataProviderPlugin::class, $plugins['process_custom_order_fees']['instance']); 30 | } 31 | 32 | #[DataFixture('JosephLeedy_CustomFees::../test/Integration/_files/orders_with_custom_fees.php')] 33 | public function testProcessesCustomFeesInOrderGridItems(): void 34 | { 35 | $objectManager = Bootstrap::getObjectManager(); 36 | /** @var DataProvider $dataProvider */ 37 | $dataProvider = $objectManager->create( 38 | DataProvider::class, 39 | [ 40 | 'name' => 'sales_order_grid_data_source', 41 | 'requestFieldName' => 'id', 42 | 'primaryFieldName' => 'main_table.entity_id', 43 | ] 44 | ); 45 | 46 | /** 47 | * @var array{ 48 | * items: array 54 | * } $data 55 | */ 56 | $data = $dataProvider->getData(); 57 | 58 | self::assertArrayHasKey('test_fee_0_base', $data['items'][0]); 59 | self::assertArrayHasKey('test_fee_0', $data['items'][0]); 60 | self::assertArrayHasKey('test_fee_1_base', $data['items'][0]); 61 | self::assertArrayHasKey('test_fee_1', $data['items'][0]); 62 | } 63 | 64 | #[DataFixture('Magento/Sales/_files/invoice_list.php')] 65 | public function testDoesNotProcessesCustomFeesInOrderGridItems(): void 66 | { 67 | $objectManager = Bootstrap::getObjectManager(); 68 | /** @var DataProvider $dataProvider */ 69 | $dataProvider = $objectManager->create( 70 | DataProvider::class, 71 | [ 72 | 'name' => 'sales_order_invoice_grid_data_source', 73 | 'requestFieldName' => 'id', 74 | 'primaryFieldName' => 'entity_id', 75 | ] 76 | ); 77 | 78 | /** 79 | * @var array{items: array>} $data 80 | */ 81 | $data = $dataProvider->getData(); 82 | 83 | self::assertArrayNotHasKey('test_fee_0_base', $data['items'][0]); 84 | self::assertArrayNotHasKey('test_fee_0', $data['items'][0]); 85 | self::assertArrayNotHasKey('test_fee_1_base', $data['items'][0]); 86 | self::assertArrayNotHasKey('test_fee_1', $data['items'][0]); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /test/Integration/Plugin/Reports/Model/ResourceModel/Refresh/CollectionPluginTest.php: -------------------------------------------------------------------------------- 1 | get(DateTime::class); 25 | /** @var Flag $flag */ 26 | $flag = $objectManager->get(Flag::class); 27 | /** @var Collection $collection */ 28 | $collection = $objectManager->create(Collection::class); 29 | 30 | $flag->setReportFlagCode('report_custom_order_fees_aggregated')->loadSelf(); 31 | $flag->setLastUpdate($dateTime->gmtDate())->save(); 32 | 33 | $collection->load(); 34 | 35 | $customOrderFeesReport = $collection->getItemById('custom_order_fees'); 36 | $expectedData = [ 37 | 'id' => 'custom_order_fees', 38 | 'report' => __('Custom Order Fees'), 39 | 'comment' => __('Total Custom Order Fees Report'), 40 | 'updated_at' => $flag->getLastUpdate(), 41 | ]; 42 | 43 | self::assertNotNull($customOrderFeesReport); 44 | self::assertEquals($expectedData, $customOrderFeesReport->getData()); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/Integration/Plugin/Sales/Api/OrderRepositoryInterfacePluginTest.php: -------------------------------------------------------------------------------- 1 | create(PluginList::class); 28 | /** 29 | * @var array{ 30 | * add_custom_fees_to_order?: array{sortOrder: int, instance: class-string}, 31 | * save_custom_order_fees?: array{sortOrder: int, instance: class-string} 32 | * } $plugins 33 | */ 34 | $plugins = $pluginList->get(OrderRepositoryInterface::class, []); 35 | 36 | self::assertArrayHasKey('add_custom_fees_to_order', $plugins); 37 | self::assertArrayHasKey('save_custom_order_fees', $plugins); 38 | // @phpstan-ignore-next-line 39 | self::assertSame(OrderRepositoryInterfacePlugin::class, $plugins['add_custom_fees_to_order']['instance']); 40 | // @phpstan-ignore-next-line 41 | self::assertSame(OrderRepositoryInterfacePlugin::class, $plugins['save_custom_order_fees']['instance']); 42 | } 43 | 44 | /** 45 | * @magentoDataFixture JosephLeedy_CustomFees::../test/Integration/_files/order_with_custom_fees.php 46 | */ 47 | public function testGetsCustomOrderFeesForAnOrder(): void 48 | { 49 | $objectManager = Bootstrap::getObjectManager(); 50 | /** @var Order $order */ 51 | $order = $objectManager->create(OrderInterface::class); 52 | /** @var OrderResource $orderResource */ 53 | $orderResource = $objectManager->create(OrderResource::class); 54 | /** @var OrderRepositoryInterface $orderRepository */ 55 | $orderRepository = $objectManager->create(OrderRepositoryInterface::class); 56 | $expectedCustomOrderFees = [ 57 | '_1727299833817_817' => [ 58 | 'code' => 'test_fee_0', 59 | 'title' => 'Test Fee', 60 | 'base_value' => 5.00, 61 | 'value' => 5.00 62 | ], 63 | '_1727299843197_197' => [ 64 | 'code' => 'test_fee_1', 65 | 'title' => 'Another Test Fee', 66 | 'base_value' => 1.50, 67 | 'value' => 1.50 68 | ] 69 | ]; 70 | 71 | // Load the order by its increment ID to avoid hard-coding the entity ID, which can change. 72 | $orderResource->load($order, '100000001', 'increment_id'); 73 | 74 | // Reload the entire order to ensure that custom fees are added 75 | /** @var int $orderId */ 76 | $orderId = $order->getId(); 77 | $fullOrder = $orderRepository->get($orderId); 78 | 79 | unset($order); 80 | 81 | $actualCustomOrderFees = $fullOrder->getExtensionAttributes() 82 | ?->getCustomOrderFees() 83 | ?->getCustomFees(); 84 | 85 | self::assertEquals($expectedCustomOrderFees, $actualCustomOrderFees); 86 | } 87 | 88 | /** 89 | * @magentoDataFixture Magento/Sales/_files/order.php 90 | */ 91 | public function testSavesCustomFeesForAnOrder(): void 92 | { 93 | $objectManager = Bootstrap::getObjectManager(); 94 | /** @var Order $order */ 95 | $order = $objectManager->create(OrderInterface::class); 96 | /** @var OrderResource $orderResource */ 97 | $orderResource = $objectManager->create(OrderResource::class); 98 | /** @var OrderRepositoryInterface $orderRepository */ 99 | $orderRepository = $objectManager->create(OrderRepositoryInterface::class); 100 | /** @var CustomOrderFeesInterface $customOrderFees */ 101 | $customOrderFees = $objectManager->create(CustomOrderFeesInterface::class); 102 | 103 | // Load the order by its increment ID to avoid hard-coding the entity ID, which can change. 104 | $orderResource->load($order, '100000001', 'increment_id'); 105 | 106 | /** @var int|string $orderId */ 107 | $orderId = $order->getEntityId(); 108 | 109 | $customOrderFees->setOrderId($orderId); 110 | $customOrderFees->setCustomFees( 111 | [ 112 | '_1726874777_074' => [ 113 | 'code' => 'test_fee_0', 114 | 'title' => 'Test Fee', 115 | 'base_value' => 5.00, 116 | 'value' => 4.50 117 | ], 118 | '_1726874800_591' => [ 119 | 'code' => 'test_fee_1', 120 | 'title' => 'Another Test Fee', 121 | 'base_value' => 1.50, 122 | 'value' => 1.35 123 | ] 124 | ] 125 | ); 126 | 127 | $order->getExtensionAttributes() 128 | ?->setCustomOrderFees($customOrderFees); 129 | 130 | $orderRepository->save($order); 131 | 132 | self::assertIsNumeric($customOrderFees->getId()); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /test/Integration/Plugin/Ui/Component/AbstractComponentPluginTest.php: -------------------------------------------------------------------------------- 1 | create(PluginList::class); 26 | /** 27 | * @var array{add_custom_fees_columns_to_order_grid: array{sortOrder: int, instance: class-string}} $plugins 28 | */ 29 | $plugins = $pluginList->get(AbstractComponent::class, []); 30 | 31 | self::assertArrayHasKey('add_custom_fees_columns_to_order_grid', $plugins); 32 | self::assertSame(AbstractComponentPlugin::class, $plugins['add_custom_fees_columns_to_order_grid']['instance']); 33 | } 34 | 35 | #[DataFixture('JosephLeedy_CustomFees::../test/Integration/_files/orders_with_custom_fees.php')] 36 | public function testAddsCustomFeeColumnsToSalesOrderGrid(): void 37 | { 38 | $objectManager = Bootstrap::getObjectManager(); 39 | /** @var DataProvider $dataProvider */ 40 | $dataProvider = $objectManager->create( 41 | DataProvider::class, 42 | [ 43 | 'name' => 'sales_order_grid_data_source', 44 | 'requestFieldName' => 'id', 45 | 'primaryFieldName' => 'main_table.entity_id', 46 | ] 47 | ); 48 | /** @var ContextInterface $context */ 49 | $context = $objectManager->create( 50 | ContextInterface::class, 51 | [ 52 | 'dataProvider' => $dataProvider, 53 | ] 54 | ); 55 | /** @var Columns $salesOrderGridColumns */ 56 | $salesOrderGridColumns = $objectManager->create( 57 | Columns::class, 58 | [ 59 | 'context' => $context, 60 | 'data' => [ 61 | 'name' => 'sales_order_columns', 62 | ] 63 | ] 64 | ); 65 | 66 | $salesOrderGridColumns->prepare(); 67 | 68 | $components = $salesOrderGridColumns->getChildComponents(); 69 | 70 | self::assertArrayHasKey('test_fee_0_base', $components); 71 | self::assertArrayHasKey('test_fee_0', $components); 72 | self::assertArrayHasKey('test_fee_1_base', $components); 73 | self::assertArrayHasKey('test_fee_1', $components); 74 | } 75 | 76 | public function testDoesNotAddCustomFeeColumnsToSalesOrderGrid(): void 77 | { 78 | $objectManager = Bootstrap::getObjectManager(); 79 | /** @var Columns $columns */ 80 | $columns = $objectManager->create( 81 | Columns::class, 82 | [ 83 | 'data' => [ 84 | 'name' => 'sales_order_invoice_columns', 85 | ] 86 | ] 87 | ); 88 | 89 | $columns->prepare(); 90 | 91 | $components = $columns->getChildComponents(); 92 | 93 | self::assertArrayNotHasKey('test_fee_0_base', $components); 94 | self::assertArrayNotHasKey('test_fee_0', $components); 95 | self::assertArrayNotHasKey('test_fee_1_base', $components); 96 | self::assertArrayNotHasKey('test_fee_1', $components); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /test/Integration/Service/CustomFeesRetrieverTest.php: -------------------------------------------------------------------------------- 1 | create(CustomFeesRetriever::class); 31 | 32 | if ($source === 'order_extension') { 33 | /** @var SearchCriteriaBuilder $searchCriteriaBuilder */ 34 | $searchCriteriaBuilder = $objectManager->create(SearchCriteriaBuilder::class); 35 | /** @var SearchCriteriaInterface $searchCriteria */ 36 | $searchCriteria = $searchCriteriaBuilder->addFilter('increment_id', '100000001') 37 | ->create(); 38 | /** @var OrderRepositoryInterface $orderRepository */ 39 | $orderRepository = $objectManager->create(OrderRepositoryInterface::class); 40 | $orders = $orderRepository->getList($searchCriteria) 41 | ->getItems(); 42 | /** @var Order $order */ 43 | $order = current($orders); 44 | } else { 45 | /** @var Order $order */ 46 | $order = $objectManager->create(Order::class); 47 | /** @var OrderResource $orderResource */ 48 | $orderResource = $objectManager->create(OrderResource::class); 49 | 50 | $orderResource->load($order, '100000001', 'increment_id'); 51 | } 52 | 53 | $expectedCustomFees = [ 54 | '_1727299833817_817' => [ 55 | 'code' => 'test_fee_0', 56 | 'title' => 'Test Fee', 57 | 'base_value' => 5.00, 58 | 'value' => 5.00 59 | ], 60 | '_1727299843197_197' => [ 61 | 'code' => 'test_fee_1', 62 | 'title' => 'Another Test Fee', 63 | 'base_value' => 1.50, 64 | 'value' => 1.50 65 | ] 66 | ]; 67 | $actualCustomFees = $customFeesRetriever->retrieve($order); 68 | 69 | self::assertEquals($expectedCustomFees, $actualCustomFees); 70 | } 71 | 72 | /** 73 | * @dataProvider doesNotRetrieveCustomFeesDataProvider 74 | * @magentoDataFixture Magento/Sales/_files/order.php 75 | */ 76 | public function testDoesNotRetrieveCustomFeesForOrder(string $condition): void 77 | { 78 | /** @var ObjectManagerInterface $objectManager */ 79 | $objectManager = Bootstrap::getObjectManager(); 80 | /** @var CustomFeesRetriever $customFeesRetriever */ 81 | $customFeesRetriever = $objectManager->create(CustomFeesRetriever::class); 82 | 83 | if ($condition === 'order_extension_null') { 84 | $order = $this->createPartialMock(Order::class, ['getExtensionAttributes']); 85 | 86 | $order->method('getExtensionAttributes') 87 | ->willReturn(null); 88 | } else { 89 | /** @var Order $order */ 90 | $order = $objectManager->create(Order::class); 91 | /** @var OrderResource $orderResource */ 92 | $orderResource = $objectManager->create(OrderResource::class); 93 | 94 | $orderResource->load($order, '100000001', 'increment_id'); 95 | } 96 | 97 | $customFees = $customFeesRetriever->retrieve($order); 98 | 99 | self::assertEmpty($customFees); 100 | } 101 | 102 | /** 103 | * @return array> 104 | */ 105 | public function retrievesCustomFeesDataProvider(): array 106 | { 107 | return [ 108 | 'from extension attribute' => [ 109 | 'source' => 'order_extension' 110 | ], 111 | 'from custom order fees database table' => [ 112 | 'source' => 'custom_order_fees_table' 113 | ], 114 | ]; 115 | } 116 | 117 | /** 118 | * @return array> 119 | */ 120 | public function doesNotRetrieveCustomFeesDataProvider(): array 121 | { 122 | return [ 123 | 'extension attribute not instantiated' => [ 124 | 'condition' => 'order_extension_null' 125 | ], 126 | 'no custom fees for order' => [ 127 | 'source' => 'no_custom_order_fees' 128 | ], 129 | ]; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /test/Integration/_files/aggregated_custom_order_fees.php: -------------------------------------------------------------------------------- 1 | requireDataFixture( 11 | 'JosephLeedy_CustomFees::../test/Integration/_files/orders_with_custom_fees_multicurrency.php', 12 | ); 13 | 14 | $objectManager = Bootstrap::getObjectManager(); 15 | /** @var CustomOrderFees $customOrderFeesReportResource */ 16 | $customOrderFeesReportResource = $objectManager->create(CustomOrderFees::class); 17 | 18 | $customOrderFeesReportResource->aggregate(); 19 | -------------------------------------------------------------------------------- /test/Integration/_files/aggregated_custom_order_fees_rollback.php: -------------------------------------------------------------------------------- 1 | requireDataFixture( 11 | 'JosephLeedy_CustomFees::../test/Integration/_files/orders_with_custom_fees_multicurrency_rollback.php', 12 | ); 13 | 14 | $objectManager = Bootstrap::getObjectManager(); 15 | /** @var Collection $customOrderFeesReportCollection */ 16 | $customOrderFeesReportCollection = $objectManager->create(Collection::class); 17 | 18 | $customOrderFeesReportCollection->walk('delete'); 19 | -------------------------------------------------------------------------------- /test/Integration/_files/creditmemo.php: -------------------------------------------------------------------------------- 1 | requireDataFixture('JosephLeedy_CustomFees::../test/Integration/_files/invoice.php'); 15 | 16 | $objectManager = Bootstrap::getObjectManager(); 17 | /** @var Order $order */ 18 | $order = $objectManager->create(Order::class); 19 | 20 | $order->loadByIncrementId('100000001'); 21 | 22 | /** @var CreditmemoFactory $creditmemoFactory */ 23 | $creditmemoFactory = $objectManager->create(CreditmemoFactory::class); 24 | $creditmemo = $creditmemoFactory->createByOrder($order, $order->getData()); 25 | /** @var CreditmemoManagementInterface $creditmemoManagement */ 26 | $creditmemoManagement = $objectManager->create(CreditmemoManagementInterface::class); 27 | 28 | /** @var Invoice $invoice */ 29 | $invoice = $order->getInvoiceCollection()->getFirstItem(); 30 | 31 | $creditmemo->setInvoice($invoice); 32 | 33 | $creditmemoManagement->refund($creditmemo); 34 | -------------------------------------------------------------------------------- /test/Integration/_files/creditmemo_rollback.php: -------------------------------------------------------------------------------- 1 | requireDataFixture('Magento/Sales/_files/default_rollback.php'); 10 | -------------------------------------------------------------------------------- /test/Integration/_files/creditmemo_with_custom_fees.php: -------------------------------------------------------------------------------- 1 | requireDataFixture('Magento/Sales/_files/default_rollback.php'); 15 | $resolver->requireDataFixture('JosephLeedy_CustomFees::../test/Integration/_files/invoice_with_custom_fees.php'); 16 | 17 | $objectManager = Bootstrap::getObjectManager(); 18 | /** @var Order $order */ 19 | $order = $objectManager->create(Order::class); 20 | 21 | $order->loadByIncrementId('100000001'); 22 | 23 | /** @var CreditmemoFactory $creditmemoFactory */ 24 | $creditmemoFactory = $objectManager->create(CreditmemoFactory::class); 25 | $creditmemo = $creditmemoFactory->createByOrder($order, $order->getData()); 26 | /** @var CreditmemoManagementInterface $creditmemoManagement */ 27 | $creditmemoManagement = $objectManager->create(CreditmemoManagementInterface::class); 28 | 29 | /** @var Invoice $invoice */ 30 | $invoice = $order->getInvoiceCollection()->getFirstItem(); 31 | 32 | $creditmemo->setInvoice($invoice); 33 | 34 | $creditmemoManagement->refund($creditmemo); 35 | -------------------------------------------------------------------------------- /test/Integration/_files/creditmemo_with_custom_fees_rollback.php: -------------------------------------------------------------------------------- 1 | requireDataFixture('JosephLeedy_CustomFees::../test/Integration/_files/creditmemo_rollback.php'); 10 | $resolver->requireDataFixture('JosephLeedy_CustomFees::../test/Integration/_files/custom_fees_rollback.php'); 11 | -------------------------------------------------------------------------------- /test/Integration/_files/custom_fees_rollback.php: -------------------------------------------------------------------------------- 1 | create(CustomOrderFeesCollection::class); 12 | 13 | /** @var CustomOrderFees $customOrderFees */ 14 | foreach ($customOrderFeesCollection as $customOrderFees) { 15 | $customOrderFees->delete(); 16 | } 17 | -------------------------------------------------------------------------------- /test/Integration/_files/invoice.php: -------------------------------------------------------------------------------- 1 | requireDataFixture('Magento/Sales/_files/default_rollback.php'); 15 | $resolver->requireDataFixture('JosephLeedy_CustomFees::../test/Integration/_files/order.php'); 16 | 17 | /** @var ObjectManagerInterface $objectManager */ 18 | $objectManager = Bootstrap::getObjectManager(); 19 | /** @var Order $order */ 20 | $order = $objectManager->create(Order::class); 21 | 22 | $order->loadByIncrementId('100000001'); 23 | 24 | /** @var InvoiceService $invoiceService */ 25 | $invoiceService = $objectManager->create(InvoiceService::class); 26 | $invoice = $invoiceService->prepareInvoice($order); 27 | 28 | $invoice->register(); 29 | 30 | $order = $invoice->getOrder(); 31 | 32 | $order->setIsInProcess(true); 33 | 34 | $transactionSave = $objectManager->create(Transaction::class); 35 | 36 | $transactionSave->addObject($invoice) 37 | ->addObject($order) 38 | ->save(); 39 | -------------------------------------------------------------------------------- /test/Integration/_files/invoice_rollback.php: -------------------------------------------------------------------------------- 1 | requireDataFixture('Magento/Sales/_files/default_rollback.php'); 8 | -------------------------------------------------------------------------------- /test/Integration/_files/invoice_with_custom_fees.php: -------------------------------------------------------------------------------- 1 | requireDataFixture('Magento/Sales/_files/default_rollback.php'); 15 | $resolver->requireDataFixture('JosephLeedy_CustomFees::../test/Integration/_files/order_with_custom_fees.php'); 16 | 17 | /** @var ObjectManagerInterface $objectManager */ 18 | $objectManager = Bootstrap::getObjectManager(); 19 | /** @var Order $order */ 20 | $order = $objectManager->create(Order::class); 21 | 22 | $order->loadByIncrementId('100000001'); 23 | 24 | /** @var InvoiceService $invoiceService */ 25 | $invoiceService = $objectManager->create(InvoiceService::class); 26 | $invoice = $invoiceService->prepareInvoice($order); 27 | 28 | $invoice->register(); 29 | 30 | $order = $invoice->getOrder(); 31 | 32 | $order->setIsInProcess(true); 33 | 34 | /** @var Transaction $transactionSave */ 35 | $transactionSave = $objectManager->create(Transaction::class); 36 | 37 | $transactionSave->addObject($invoice) 38 | ->addObject($order) 39 | ->save(); 40 | -------------------------------------------------------------------------------- /test/Integration/_files/invoice_with_custom_fees_rollback.php: -------------------------------------------------------------------------------- 1 | requireDataFixture('Magento/Sales/_files/invoice_rollback.php'); 10 | $resolver->requireDataFixture('JosephLeedy_CustomFees::../test/Integration/_files/custom_fees_rollback.php'); 11 | -------------------------------------------------------------------------------- /test/Integration/_files/order.php: -------------------------------------------------------------------------------- 1 | requireDataFixture('Magento/Sales/_files/order.php'); 14 | 15 | /** @var ObjectManagerInterface $objectManager */ 16 | $objectManager = Bootstrap::getObjectManager(); 17 | /** @var Order $order */ 18 | $order = $objectManager->create(Order::class); 19 | 20 | $order->loadByIncrementId('100000001'); 21 | 22 | $orderItems = $order->getAllItems(); 23 | $baseOrderTotal = 0; 24 | $orderTotal = 0; 25 | 26 | foreach ($orderItems as $orderItem) { 27 | $orderItem->setBaseRowTotal($orderItem->getBasePrice() * $orderItem->getQtyOrdered()); 28 | $orderItem->setRowTotal($orderItem->getPrice() * $orderItem->getQtyOrdered()); 29 | 30 | $baseOrderTotal += $orderItem->getBaseRowTotal(); 31 | $orderTotal += $orderItem->getRowTotal(); 32 | } 33 | 34 | $order->setBaseSubtotal($baseOrderTotal); 35 | $order->setSubtotal($orderTotal); 36 | $order->setBaseGrandTotal($baseOrderTotal); 37 | $order->setGrandTotal($orderTotal); 38 | 39 | /** @var OrderRepositoryInterface $orderRepository */ 40 | $orderRepository = $objectManager->create(OrderRepositoryInterface::class); 41 | 42 | $orderRepository->save($order); 43 | -------------------------------------------------------------------------------- /test/Integration/_files/order_rollback.php: -------------------------------------------------------------------------------- 1 | requireDataFixture('Magento/Sales/_files/order_rollback.php'); 10 | -------------------------------------------------------------------------------- /test/Integration/_files/order_with_custom_fees.php: -------------------------------------------------------------------------------- 1 | requireDataFixture('JosephLeedy_CustomFees::../test/Integration/_files/order.php'); 15 | 16 | $objectManager = Bootstrap::getObjectManager(); 17 | /** @var Order $order */ 18 | $order = $objectManager->create(Order::class); 19 | /** @var OrderResource $orderResource */ 20 | $orderResource = $objectManager->create(OrderResource::class); 21 | /** @var CustomOrderFeesInterfaceFactory $customOrderFeesFactory */ 22 | $customOrderFeesFactory = $objectManager->create(CustomOrderFeesInterfaceFactory::class); 23 | /** @var CustomOrderFeesInterface $customOrderFees */ 24 | $customOrderFees = $customOrderFeesFactory->create(); 25 | /** @var CustomOrderFeesRepository $customOrderFeesRepository */ 26 | $customOrderFeesRepository = $objectManager->create(CustomOrderFeesRepositoryInterface::class); 27 | $testCustomFees = [ 28 | '_1727299833817_817' => [ 29 | 'code' => 'test_fee_0', 30 | 'title' => 'Test Fee', 31 | 'base_value' => 5.00, 32 | 'value' => 5.00 33 | ], 34 | '_1727299843197_197' => [ 35 | 'code' => 'test_fee_1', 36 | 'title' => 'Another Test Fee', 37 | 'base_value' => 1.50, 38 | 'value' => 1.50 39 | ] 40 | ]; 41 | 42 | $orderResource->load($order, '100000001', 'increment_id'); 43 | 44 | /** @var int $orderId */ 45 | $orderId = $order->getEntityId() ?? 0; 46 | 47 | $customOrderFees->setOrderId($orderId); 48 | $customOrderFees->setCustomFees($testCustomFees); 49 | 50 | $customOrderFeesRepository->save($customOrderFees); 51 | 52 | $order->getExtensionAttributes() 53 | ?->setCustomOrderFees($customOrderFees); 54 | -------------------------------------------------------------------------------- /test/Integration/_files/order_with_custom_fees_rollback.php: -------------------------------------------------------------------------------- 1 | requireDataFixture('Magento/Sales/_files/order_rollback.php'); 10 | $resolver->requireDataFixture('JosephLeedy_CustomFees::../test/Integration/_files/custom_fees_rollback.php'); 11 | -------------------------------------------------------------------------------- /test/Integration/_files/order_with_example_custom_fee.php: -------------------------------------------------------------------------------- 1 | requireDataFixture('JosephLeedy_CustomFees::../test/Integration/_files/order.php'); 15 | 16 | $objectManager = Bootstrap::getObjectManager(); 17 | /** @var Order $order */ 18 | $order = $objectManager->create(Order::class); 19 | /** @var OrderResource $orderResource */ 20 | $orderResource = $objectManager->create(OrderResource::class); 21 | /** @var CustomOrderFeesInterfaceFactory $customOrderFeesFactory */ 22 | $customOrderFeesFactory = $objectManager->create(CustomOrderFeesInterfaceFactory::class); 23 | /** @var CustomOrderFeesInterface $customOrderFees */ 24 | $customOrderFees = $customOrderFeesFactory->create(); 25 | /** @var CustomOrderFeesRepository $customOrderFeesRepository */ 26 | $customOrderFeesRepository = $objectManager->create(CustomOrderFeesRepositoryInterface::class); 27 | $customFees = [ 28 | [ 29 | 'code' => 'example_fee', 30 | 'title' => 'Example Fee', 31 | 'base_value' => 0.00, 32 | 'value' => 0.00 33 | ] 34 | ]; 35 | 36 | $orderResource->load($order, '100000001', 'increment_id'); 37 | 38 | /** @var int $orderId */ 39 | $orderId = $order->getEntityId() ?? 0; 40 | 41 | $customOrderFees->setOrderId($orderId); 42 | $customOrderFees->setCustomFees($customFees); 43 | 44 | $customOrderFeesRepository->save($customOrderFees); 45 | 46 | $order->getExtensionAttributes() 47 | ?->setCustomOrderFees($customOrderFees); 48 | -------------------------------------------------------------------------------- /test/Integration/_files/order_with_example_custom_fee_rollback.php: -------------------------------------------------------------------------------- 1 | requireDataFixture('Magento/Sales/_files/order_rollback.php'); 10 | $resolver->requireDataFixture('JosephLeedy_CustomFees::../test/Integration/_files/custom_fees_rollback.php'); 11 | -------------------------------------------------------------------------------- /test/Integration/_files/orders_with_custom_fees.php: -------------------------------------------------------------------------------- 1 | requireDataFixture('Magento/Sales/_files/order_list.php'); 13 | 14 | $objectManager = Bootstrap::getObjectManager(); 15 | /** @var OrderCollection $orderCollection */ 16 | $orderCollection = $objectManager->create(OrderCollection::class); 17 | /** @var OrderInterface[] $orders */ 18 | $orders = $orderCollection->addFieldToFilter( 19 | 'increment_id', 20 | [ 21 | 'in' => [ 22 | '100000001', 23 | '100000002', 24 | '100000003', 25 | '100000004' 26 | ] 27 | ] 28 | )->getItems(); 29 | $testCustomFees = [ 30 | '_1727299122629_629' => [ 31 | 'code' => 'test_fee_0', 32 | 'title' => 'Test Fee', 33 | 'base_value' => 5.00, 34 | 'value' => 5.00 35 | ], 36 | '_1727299257083_083' => [ 37 | 'code' => 'test_fee_1', 38 | 'title' => 'Another Test Fee', 39 | 'base_value' => 1.50, 40 | 'value' => 1.50 41 | ] 42 | ]; 43 | /** @var CustomOrderFeesInterfaceFactory $customOrderFeesFactory */ 44 | $customOrderFeesFactory = $objectManager->create(CustomOrderFeesInterfaceFactory::class); 45 | /** @var CustomOrderFeesRepositoryInterface $customOrderFeesRepository */ 46 | $customOrderFeesRepository = $objectManager->create(CustomOrderFeesRepositoryInterface::class); 47 | 48 | foreach ($orders as $order) { 49 | $customOrderFees = $customOrderFeesFactory->create(); 50 | 51 | $customOrderFees->setOrderId($order->getEntityId() ?? 0); 52 | $customOrderFees->setCustomFees($testCustomFees); 53 | 54 | $customOrderFeesRepository->save($customOrderFees); 55 | 56 | $order->getExtensionAttributes() 57 | ?->setCustomOrderFees($customOrderFees); 58 | } 59 | -------------------------------------------------------------------------------- /test/Integration/_files/orders_with_custom_fees_multicurrency.php: -------------------------------------------------------------------------------- 1 | requireDataFixture('Magento/Sales/_files/order_list.php'); 15 | 16 | $objectManager = Bootstrap::getObjectManager(); 17 | /** @var OrderCollection $orderCollection */ 18 | $orderCollection = $objectManager->create(OrderCollection::class); 19 | /** @var OrderInterface[] $orders */ 20 | $orders = $orderCollection 21 | ->addFieldToFilter( 22 | 'increment_id', 23 | [ 24 | 'in' => [ 25 | '100000001', 26 | '100000002', 27 | '100000003', 28 | '100000004', 29 | ], 30 | ], 31 | )->getItems(); 32 | $testCustomFees = [ 33 | '_1727299122629_629' => [ 34 | 'code' => 'test_fee_0', 35 | 'title' => 'Test Fee', 36 | 'base_value' => 5.00, 37 | 'value' => 5.00, 38 | ], 39 | '_1727299257083_083' => [ 40 | 'code' => 'test_fee_1', 41 | 'title' => 'Another Test Fee', 42 | 'base_value' => 1.50, 43 | 'value' => 1.50, 44 | ], 45 | ]; 46 | /** @var CustomOrderFeesInterfaceFactory $customOrderFeesFactory */ 47 | $customOrderFeesFactory = $objectManager->create(CustomOrderFeesInterfaceFactory::class); 48 | /** @var CustomOrderFeesRepositoryInterface $customOrderFeesRepository */ 49 | $customOrderFeesRepository = $objectManager->create(CustomOrderFeesRepositoryInterface::class); 50 | /** @var Currency $currency */ 51 | $currency = $objectManager->get(Currency::class); 52 | $rate = $currency->load('USD')->getRate('EUR'); 53 | /** @var PriceCurrencyInterface $priceCurrency */ 54 | $priceCurrency = $objectManager->get(PriceCurrencyInterface::class); 55 | 56 | foreach ($orders as $key => $order) { 57 | $customFeesForOrder = $testCustomFees; 58 | 59 | if ($key % 2 === 0) { 60 | $order->setOrderCurrencyCode('EUR'); 61 | $order->setBaseToOrderRate($rate); 62 | $order->save(); 63 | 64 | $customFeesForOrder['_1727299122629_629']['value'] = $priceCurrency->convert( 65 | $customFeesForOrder['_1727299122629_629']['value'], 66 | $order->getStoreId(), 67 | $order->getOrderCurrencyCode(), 68 | ); 69 | $customFeesForOrder['_1727299257083_083']['value'] = $priceCurrency->convert( 70 | $customFeesForOrder['_1727299257083_083']['value'], 71 | $order->getStoreId(), 72 | $order->getOrderCurrencyCode(), 73 | ); 74 | } 75 | 76 | if ($order->getOrderCurrencyCode() === null) { 77 | $order->setOrderCurrencyCode('USD'); 78 | $order->setBaseToOrderRate(1); 79 | $order->save(); 80 | } 81 | 82 | $customOrderFees = $customOrderFeesFactory->create(); 83 | 84 | $customOrderFees->setOrderId($order->getEntityId() ?? 0); 85 | $customOrderFees->setCustomFees($customFeesForOrder); 86 | 87 | $customOrderFeesRepository->save($customOrderFees); 88 | 89 | $order->getExtensionAttributes()?->setCustomOrderFees($customOrderFees); 90 | } 91 | -------------------------------------------------------------------------------- /test/Integration/_files/orders_with_custom_fees_multicurrency_rollback.php: -------------------------------------------------------------------------------- 1 | requireDataFixture('Magento/Sales/_files/order_list_rollback.php'); 10 | $resolver->requireDataFixture('JosephLeedy_CustomFees::../test/Integration/_files/custom_fees_rollback.php'); 11 | -------------------------------------------------------------------------------- /test/Integration/_files/orders_with_custom_fees_rollback.php: -------------------------------------------------------------------------------- 1 | requireDataFixture('Magento/Sales/_files/order_list_rollback.php'); 10 | $resolver->requireDataFixture('JosephLeedy_CustomFees::../test/Integration/_files/custom_fees_rollback.php'); 11 | --------------------------------------------------------------------------------