Files
simple-withdrawalbutton/simple_withdrawalbutton.php
T
Arne Weiss 058937bd64 Add CSS hook, URL ref prefill, order prefill, and Widerrufsfrist display
- Fix CSS loading: register stylesheet via actionFrontControllerSetMedia
  hook instead of initContent() — guaranteed to fire before page render
- Upgrade script registers new hook on existing installations
- Feature 1: pre-fill order_reference from ?ref= URL parameter
- Feature 2: pre-fill customer name/email from logged-in account when
  navigating with ?ref= (already worked; now order ref is also pre-filled)
- Feature 4: compute Widerrufsfrist (order date +14 days) from DB and
  display on form (when ref in URL), confirm, and success pages
- Feature 3: EN mail templates were already present

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 08:52:53 +02:00

650 lines
24 KiB
PHP

<?php
/**
* Minimal electronic withdrawal button for PrestaShop.
*
* This module implements a minimal two-step withdrawal declaration flow:
* - public footer link "Vertrag widerrufen"
* - public form for full or partial withdrawal
* - confirmation step with button "Widerruf bestätigen"
* - database record
* - email confirmation to customer and notification to shop owner
*/
if (!defined('_PS_VERSION_')) {
exit;
}
class Simple_withdrawalbutton extends Module
{
public const TABLE_REQUEST = 'simple_withdrawal_request';
public const CONF_SHOP_EMAIL = 'SIMPLE_WITHDRAWAL_SHOP_EMAIL';
public const CONF_RATE_LIMIT = 'SIMPLE_WITHDRAWAL_RATE_LIMIT';
public const CONF_PRIVACY_URL = 'SIMPLE_WITHDRAWAL_PRIVACY_URL';
public const CONF_REVOCATION_URL = 'SIMPLE_WITHDRAWAL_REVOCATION_URL';
public const CONF_RETENTION_MONTHS = 'SIMPLE_WITHDRAWAL_RETENTION_MONTHS';
public function __construct()
{
$this->name = 'simple_withdrawalbutton';
$this->tab = 'administration';
$this->version = '0.1.4';
$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;
}
}