name = 'simple_withdrawalbutton'; $this->tab = 'administration'; $this->version = '0.1.7'; $this->author = 'Arne Weiss'; $this->need_instance = 0; $this->bootstrap = true; $this->ps_versions_compliancy = [ 'min' => '1.7.8.0', 'max' => _PS_VERSION_, ]; parent::__construct(); $this->displayName = $this->l('Withdrawal button'); $this->description = $this->l('Adds a minimal two-step electronic withdrawal function for B2C orders.'); $this->confirmUninstall = $this->l('Uninstall the module? Existing withdrawal records will be kept in the database.'); } public function install() { return parent::install() && $this->installSql() && $this->installTab() && $this->registerHook('displayFooter') && $this->registerHook('displayCustomerAccount') && $this->registerHook('actionFrontControllerSetMedia') && Configuration::updateValue(self::CONF_SHOP_EMAIL, (string) Configuration::get('PS_SHOP_EMAIL')) && Configuration::updateValue(self::CONF_RATE_LIMIT, '5') && Configuration::updateValue(self::CONF_PRIVACY_URL, '') && Configuration::updateValue(self::CONF_REVOCATION_URL, '') && Configuration::updateValue(self::CONF_RETENTION_MONTHS, '0'); } public function uninstall() { // Keep withdrawal records intentionally. These may be legally relevant. return $this->uninstallTab() && Configuration::deleteByName(self::CONF_SHOP_EMAIL) && Configuration::deleteByName(self::CONF_RATE_LIMIT) && Configuration::deleteByName(self::CONF_PRIVACY_URL) && Configuration::deleteByName(self::CONF_REVOCATION_URL) && Configuration::deleteByName(self::CONF_RETENTION_MONTHS) && parent::uninstall(); } private function installSql() { $sql = 'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . pSQL(self::TABLE_REQUEST) . '` ( `id_withdrawal_request` INT UNSIGNED NOT NULL AUTO_INCREMENT, `id_order` INT UNSIGNED NULL, `order_reference` VARCHAR(64) NOT NULL, `customer_name` VARCHAR(255) NOT NULL, `customer_email` VARCHAR(255) NOT NULL, `withdrawal_scope` ENUM("full", "partial") NOT NULL, `withdrawal_items_text` TEXT NULL, `message` TEXT NULL, `created_at` DATETIME NOT NULL, `confirmation_sent_at` DATETIME NULL, `status` ENUM("new", "processing", "closed") NOT NULL DEFAULT "new", `id_shop` INT UNSIGNED NULL, `id_lang` INT UNSIGNED NULL, `customer_ip_hash` CHAR(64) NULL, `user_agent_hash` CHAR(64) NULL, PRIMARY KEY (`id_withdrawal_request`), KEY `idx_order_reference` (`order_reference`), KEY `idx_customer_email` (`customer_email`), KEY `idx_created_at` (`created_at`), KEY `idx_status` (`status`), KEY `idx_shop` (`id_shop`) ) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;'; return Db::getInstance()->execute($sql); } private function installTab() { $className = 'AdminSimpleWithdrawal'; if ((int) Tab::getIdFromClassName($className) > 0) { return true; } $tab = new Tab(); $tab->active = 1; $tab->class_name = $className; $tab->module = $this->name; $tab->id_parent = $this->getOrdersParentTabId(); foreach (Language::getLanguages(false) as $lang) { $tab->name[(int) $lang['id_lang']] = 'Widerrufe'; } return (bool) $tab->add(); } private function uninstallTab() { $idTab = (int) Tab::getIdFromClassName('AdminSimpleWithdrawal'); if ($idTab <= 0) { return true; } $tab = new Tab($idTab); return (bool) $tab->delete(); } private function getOrdersParentTabId() { $candidates = [ 'AdminParentOrders', 'AdminOrders', 'AdminParentModulesSf', ]; foreach ($candidates as $className) { $id = (int) Tab::getIdFromClassName($className); if ($id > 0) { return $id; } } return 0; } public function getContent() { $output = ''; if (Tools::isSubmit('submitCypWithdrawalSettings')) { $email = trim((string) Tools::getValue(self::CONF_SHOP_EMAIL)); $rateLimit = (int) Tools::getValue(self::CONF_RATE_LIMIT); $privacyUrl = trim((string) Tools::getValue(self::CONF_PRIVACY_URL)); $revocationUrl = trim((string) Tools::getValue(self::CONF_REVOCATION_URL)); $retentionMonths = (int) Tools::getValue(self::CONF_RETENTION_MONTHS); if (!Validate::isEmail($email)) { $output .= $this->displayError($this->l('Please enter a valid shop notification email.')); } else { Configuration::updateValue(self::CONF_SHOP_EMAIL, $email); Configuration::updateValue(self::CONF_RATE_LIMIT, (string) max(1, min(50, $rateLimit))); Configuration::updateValue(self::CONF_PRIVACY_URL, $privacyUrl); Configuration::updateValue(self::CONF_REVOCATION_URL, $revocationUrl); Configuration::updateValue(self::CONF_RETENTION_MONTHS, (string) max(0, $retentionMonths)); $output .= $this->displayConfirmation($this->l('Settings updated.')); } } if (Tools::isSubmit('submitCypWithdrawalPurge')) { $retentionMonths = (int) Configuration::get(self::CONF_RETENTION_MONTHS); if ($retentionMonths > 0) { $deleted = $this->purgeOldRecords($retentionMonths); $output .= $this->displayConfirmation( sprintf($this->l('%d withdrawal record(s) older than %d month(s) have been deleted.'), $deleted, $retentionMonths) ); } else { $output .= $this->displayError($this->l('No retention period configured. Set a value greater than 0 first.')); } } $adminLink = $this->context->link->getAdminLink('AdminSimpleWithdrawal'); $withdrawalLink = $this->getWithdrawalLink(); $this->context->smarty->assign([ 'admin_link' => $adminLink, 'withdrawal_link' => $withdrawalLink, ]); return $output . $this->renderForm() . $this->display(__FILE__, 'views/templates/admin/config_info.tpl'); } private function renderForm() { $helper = new HelperForm(); $helper->show_toolbar = false; $helper->table = $this->table; $helper->module = $this; $helper->default_form_language = (int) Configuration::get('PS_LANG_DEFAULT'); $helper->allow_employee_form_lang = (int) Configuration::get('PS_BO_ALLOW_EMPLOYEE_FORM_LANG'); $helper->identifier = $this->name; $helper->submit_action = 'submitCypWithdrawalSettings'; $helper->currentIndex = AdminController::$currentIndex . '&configure=' . $this->name; $helper->token = Tools::getAdminTokenLite('AdminModules'); $helper->tpl_vars = [ 'fields_value' => [ self::CONF_SHOP_EMAIL => (string) Configuration::get(self::CONF_SHOP_EMAIL), self::CONF_RATE_LIMIT => (string) Configuration::get(self::CONF_RATE_LIMIT), self::CONF_PRIVACY_URL => (string) Configuration::get(self::CONF_PRIVACY_URL), self::CONF_REVOCATION_URL => (string) Configuration::get(self::CONF_REVOCATION_URL), self::CONF_RETENTION_MONTHS => (string) Configuration::get(self::CONF_RETENTION_MONTHS), ], 'languages' => $this->context->controller->getLanguages(), 'id_language' => (int) $this->context->language->id, ]; $fieldsForm = [ 'form' => [ 'legend' => [ 'title' => $this->l('Withdrawal button settings'), 'icon' => 'icon-undo', ], 'input' => [ [ 'type' => 'text', 'label' => $this->l('Shop notification email'), 'name' => self::CONF_SHOP_EMAIL, 'required' => true, 'desc' => $this->l('New withdrawal declarations are sent to this address.'), ], [ 'type' => 'text', 'label' => $this->l('Rate limit per hour'), 'name' => self::CONF_RATE_LIMIT, 'required' => true, 'desc' => $this->l('Maximum saved withdrawal declarations per email or IP hash per hour.'), ], [ 'type' => 'text', 'label' => $this->l('Privacy policy URL'), 'name' => self::CONF_PRIVACY_URL, 'required' => false, 'desc' => $this->l('Link to your Datenschutzerklärung shown in the withdrawal form (GDPR Art. 13). Leave empty to show plain text without link.'), ], [ 'type' => 'text', 'label' => $this->l('Widerrufsbelehrung URL'), 'name' => self::CONF_REVOCATION_URL, 'required' => false, 'desc' => $this->l('Link to your revocation policy page shown above the withdrawal form. Leave empty to hide the link.'), ], [ 'type' => 'text', 'label' => $this->l('Retention period (months)'), 'name' => self::CONF_RETENTION_MONTHS, 'required' => false, 'desc' => $this->l('Delete withdrawal records older than this many months when purging. Set to 0 to keep records indefinitely (GDPR: define a retention period).'), ], ], 'buttons' => [ [ 'title' => $this->l('Purge old records'), 'name' => 'submitCypWithdrawalPurge', 'icon' => 'process-icon-delete', 'class' => 'btn btn-default pull-right', ], ], 'submit' => [ 'title' => $this->l('Save'), ], ], ]; return $helper->generateForm([$fieldsForm]); } public function hookDisplayFooter(array $params) { $this->context->smarty->assign([ 'withdrawal_link' => $this->getWithdrawalLink(), ]); return $this->display(__FILE__, 'views/templates/hook/footer.tpl'); } public function hookDisplayCustomerAccount(array $params) { $this->context->smarty->assign([ 'withdrawal_link' => $this->getWithdrawalLink(), ]); return $this->display(__FILE__, 'views/templates/hook/customer_account.tpl'); } public function hookActionFrontControllerSetMedia() { if ( Tools::getValue('module') === $this->name && Tools::getValue('controller') === 'withdraw' ) { $this->context->controller->registerStylesheet( 'cyp-withdrawal', 'modules/' . $this->name . '/views/css/withdrawal.css', ['media' => 'all', 'priority' => 200] ); } } public function getWithdrawalLink() { return $this->context->link->getModuleLink($this->name, 'withdraw', [], true); } public function getFrontToken() { $guestId = isset($this->context->cookie->id_guest) ? (string) $this->context->cookie->id_guest : ''; $customerId = isset($this->context->customer->id) ? (string) $this->context->customer->id : ''; return Tools::hash($this->name . '|withdraw|' . $guestId . '|' . $customerId . '|' . _COOKIE_KEY_); } public function isValidFrontToken($token) { return hash_equals($this->getFrontToken(), (string) $token); } public function cleanText($value, $maxLength = 255) { $value = trim((string) $value); $value = strip_tags($value); $value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value); if ($maxLength > 0 && Tools::strlen($value) > $maxLength) { $value = Tools::substr($value, 0, $maxLength); } return $value; } public function lookupOrderIdByReference($orderReference) { $orderReference = $this->cleanText($orderReference, 64); if ($orderReference === '') { return null; } $sql = 'SELECT `id_order` FROM `' . _DB_PREFIX_ . 'orders` WHERE `reference` = "' . pSQL($orderReference) . '" AND `id_shop` = ' . (int) $this->context->shop->id . ' ORDER BY `id_order` DESC'; $idOrder = (int) Db::getInstance()->getValue($sql); return $idOrder > 0 ? $idOrder : null; } public function lookupOrderInfoByReference($orderReference) { $orderReference = $this->cleanText($orderReference, 64); if ($orderReference === '') { return null; } $sql = 'SELECT `id_order`, `date_add`, `id_customer` FROM `' . _DB_PREFIX_ . 'orders` WHERE `reference` = "' . pSQL($orderReference) . '" AND `id_shop` = ' . (int) $this->context->shop->id . ' ORDER BY `id_order` DESC'; $row = Db::getInstance()->getRow($sql); if (!$row) { return null; } return [ 'id_order' => (int) $row['id_order'], 'date_add' => $row['date_add'], 'id_customer' => (int) $row['id_customer'], ]; } public function computeWithdrawalDeadline($orderDateAdd, $isoCode = 'de') { $timestamp = strtotime((string) $orderDateAdd); if (!$timestamp) { return null; } $deadline = strtotime('+14 days', $timestamp); return ($isoCode === 'de') ? date('d.m.Y', $deadline) : date('d/m/Y', $deadline); } public function hashForPrivacy($value) { $value = trim((string) $value); if ($value === '') { return null; } return hash('sha256', $value . '|' . _COOKIE_KEY_); } public function getClientIp() { if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) { return (string) $_SERVER['HTTP_CF_CONNECTING_IP']; } if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { $parts = explode(',', (string) $_SERVER['HTTP_X_FORWARDED_FOR']); return trim($parts[0]); } if (!empty($_SERVER['REMOTE_ADDR'])) { return (string) $_SERVER['REMOTE_ADDR']; } return ''; } public function isRateLimited($customerEmail, $ipHash) { $limit = (int) Configuration::get(self::CONF_RATE_LIMIT); if ($limit <= 0) { $limit = 5; } $conditions = ['`customer_email` = "' . pSQL($customerEmail) . '"']; if ($ipHash) { $conditions[] = '`customer_ip_hash` = "' . pSQL($ipHash) . '"'; } $sql = 'SELECT COUNT(*) FROM `' . _DB_PREFIX_ . pSQL(self::TABLE_REQUEST) . '` WHERE (`created_at` >= DATE_SUB(NOW(), INTERVAL 1 HOUR)) AND (' . implode(' OR ', $conditions) . ')'; return (int) Db::getInstance()->getValue($sql) >= $limit; } public function saveWithdrawal(array $data) { $createdAt = date('Y-m-d H:i:s'); $ipHash = $this->hashForPrivacy($this->getClientIp()); $userAgentHash = $this->hashForPrivacy(isset($_SERVER['HTTP_USER_AGENT']) ? (string) $_SERVER['HTTP_USER_AGENT'] : ''); $idOrder = $this->lookupOrderIdByReference($data['order_reference']); $insert = [ 'id_order' => $idOrder ? (int) $idOrder : null, 'order_reference' => $data['order_reference'], 'customer_name' => $data['customer_name'], 'customer_email' => $data['customer_email'], 'withdrawal_scope' => $data['withdrawal_scope'], 'withdrawal_items_text' => $data['withdrawal_items_text'] !== '' ? $data['withdrawal_items_text'] : null, 'message' => $data['message'] !== '' ? $data['message'] : null, 'created_at' => $createdAt, 'status' => 'new', 'id_shop' => (int) $this->context->shop->id, 'id_lang' => (int) $this->context->language->id, 'customer_ip_hash' => $ipHash ?: null, 'user_agent_hash' => $userAgentHash ?: null, ]; $ok = Db::getInstance()->insert(self::TABLE_REQUEST, $insert, false, true, Db::INSERT, false); if (!$ok) { return false; } $id = (int) Db::getInstance()->Insert_ID(); return [ 'id_withdrawal_request' => $id, 'created_at' => $createdAt, 'id_order' => $idOrder, 'customer_ip_hash' => $ipHash, ]; } public function markConfirmationSent($idWithdrawalRequest) { return Db::getInstance()->update( self::TABLE_REQUEST, ['confirmation_sent_at' => date('Y-m-d H:i:s')], '`id_withdrawal_request` = ' . (int) $idWithdrawalRequest ); } public function sendCustomerConfirmation(array $data, $createdAt) { $idLang = (int) $this->context->language->id; $idShop = (int) $this->context->shop->id; $isoCode = $this->getLangIsoCode($idLang); $shopName = (string) Configuration::get('PS_SHOP_NAME'); $shopEmail = (string) Configuration::get('PS_SHOP_EMAIL'); $scopeLabel = $this->getScopeLabel($data['withdrawal_scope'], $isoCode); $templateVars = [ '{customer_name}' => $data['customer_name'], '{order_reference}' => $data['order_reference'], '{scope_label}' => $scopeLabel, '{withdrawal_items_text}' => $data['withdrawal_items_text'] !== '' ? $data['withdrawal_items_text'] : '-', '{message}' => $data['message'] !== '' ? $data['message'] : '-', '{submitted_at}' => $this->formatDateTimeForMail($createdAt, $isoCode), '{shop_name}' => $shopName, ]; try { return Mail::Send( $idLang, 'withdrawal_confirmation', $this->getConfirmationSubject($isoCode), $templateVars, $data['customer_email'], $data['customer_name'], $shopEmail, $shopName, null, null, _PS_MODULE_DIR_ . $this->name . '/mails/', false, $idShop ); } catch (\Exception $e) { return false; } } public function sendShopNotification(array $data, $createdAt, $idWithdrawalRequest) { $idLang = (int) $this->context->language->id; $idShop = (int) $this->context->shop->id; $isoCode = $this->getLangIsoCode($idLang); $shopName = (string) Configuration::get('PS_SHOP_NAME'); $shopEmail = (string) Configuration::get('PS_SHOP_EMAIL'); $toEmail = (string) Configuration::get(self::CONF_SHOP_EMAIL); if (!Validate::isEmail($toEmail)) { $toEmail = $shopEmail; } $scopeLabel = $this->getScopeLabel($data['withdrawal_scope'], $isoCode); $adminLink = $this->context->link->getAdminLink('AdminSimpleWithdrawal'); $templateVars = [ '{id_withdrawal_request}' => (string) $idWithdrawalRequest, '{customer_name}' => $data['customer_name'], '{customer_email}' => $data['customer_email'], '{order_reference}' => $data['order_reference'], '{scope_label}' => $scopeLabel, '{withdrawal_items_text}' => $data['withdrawal_items_text'] !== '' ? $data['withdrawal_items_text'] : '-', '{message}' => $data['message'] !== '' ? $data['message'] : '-', '{submitted_at}' => $this->formatDateTimeForMail($createdAt, $isoCode), '{admin_link}' => $adminLink, '{shop_name}' => $shopName, ]; try { return Mail::Send( $idLang, 'withdrawal_notification', $this->getNotificationSubject($data['order_reference'], $isoCode), $templateVars, $toEmail, null, $shopEmail, $shopName, null, null, _PS_MODULE_DIR_ . $this->name . '/mails/', false, $idShop ); } catch (\Exception $e) { return false; } } public function purgeOldRecords($months) { $months = (int) $months; if ($months <= 0) { return 0; } $sql = 'SELECT COUNT(*) FROM `' . _DB_PREFIX_ . pSQL(self::TABLE_REQUEST) . '` WHERE `created_at` < DATE_SUB(NOW(), INTERVAL ' . $months . ' MONTH)'; $count = (int) Db::getInstance()->getValue($sql); if ($count > 0) { Db::getInstance()->execute( 'DELETE FROM `' . _DB_PREFIX_ . pSQL(self::TABLE_REQUEST) . '` WHERE `created_at` < DATE_SUB(NOW(), INTERVAL ' . $months . ' MONTH)' ); } return $count; } public function formatDateTimeForMail($dateTime, $isoCode = 'de') { $timestamp = strtotime((string) $dateTime); if (!$timestamp) { $timestamp = time(); } if ($isoCode === 'de') { return date('d.m.Y H:i:s', $timestamp) . ' Uhr'; } return date('Y-m-d H:i:s', $timestamp); } private function getLangIsoCode($idLang) { $iso = Language::getIsoById((int) $idLang); return $iso ?: 'de'; } private function getScopeLabel($scope, $isoCode) { $labels = [ 'de' => ['partial' => 'Teil der Bestellung', 'full' => 'gesamte Bestellung'], 'en' => ['partial' => 'part of the order', 'full' => 'full order'], ]; $map = isset($labels[$isoCode]) ? $labels[$isoCode] : $labels['de']; return $scope === 'partial' ? $map['partial'] : $map['full']; } private function getConfirmationSubject($isoCode) { $subjects = [ 'de' => 'Eingangsbestätigung Ihres Widerrufs', 'en' => 'Confirmation of receipt of your withdrawal', ]; return isset($subjects[$isoCode]) ? $subjects[$isoCode] : $subjects['de']; } private function getNotificationSubject($orderReference, $isoCode) { if ($isoCode === 'en') { return 'New withdrawal: order ' . $orderReference; } return 'Neuer Widerruf: Bestellung ' . $orderReference; } }