From 5eaf5029475e6aabbd505355aa8545ecd22938de Mon Sep 17 00:00:00 2001 From: Guillaume Argiles Date: Sun, 21 Jun 2026 18:24:18 +0200 Subject: [PATCH] Add configurable data-retention (auto-anonymization of inactive customers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enforce the GDPR storage-limitation principle (art. 5.1.e) by anonymizing customers inactive beyond a merchant-configured period. Disabled by default. - CustomerRepository::findInactiveCustomers(): "inactive" = most recent of (last connection, last valid order) older than the configured period. - DataRetentionService: two phases — warn (email + grace window) then anonymize via the existing CustomerService primitive, so orders/invoices are preserved for legal accounting retention while PII is anonymized. - Scheduling: console command `psgdpr:data-retention:run` (with `--dry-run`) plus the actionCronJob hook (ps_cronjobs). No public endpoint. - Back-office "Data retention" configuration tab (enable, inactivity period, warning lead time). - Bilingual (FR/EN) warning email templates. - Audit trail via psgdpr_log (request types: warning, scheduled anonymization). --- CONTRIBUTORS.md | 1 + config/services.yml | 18 ++ mails/en/retention_warning.html | 52 ++++ mails/en/retention_warning.txt | 14 + mails/fr/retention_warning.html | 52 ++++ mails/fr/retention_warning.txt | 14 + psgdpr.php | 63 ++++ src/Command/DataRetentionCommand.php | 101 +++++++ src/Entity/PsgdprLog.php | 2 + src/Repository/CustomerRepository.php | 72 +++++ .../DataRetention/DataRetentionService.php | 278 ++++++++++++++++++ src/Service/LoggerService.php | 2 + views/templates/admin/menu.tpl | 5 + views/templates/admin/tabs/dataRetention.tpl | 103 +++++++ 14 files changed, 777 insertions(+) create mode 100644 mails/en/retention_warning.html create mode 100644 mails/en/retention_warning.txt create mode 100644 mails/fr/retention_warning.html create mode 100644 mails/fr/retention_warning.txt create mode 100644 src/Command/DataRetentionCommand.php create mode 100644 src/Service/DataRetention/DataRetentionService.php create mode 100644 views/templates/admin/tabs/dataRetention.tpl diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 7d7bfcbf..7188b35f 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -8,5 +8,6 @@ GitHub contributors: - Shiryu75 - daresh - eternoendless + - guillaumeArgiles - netraagal - v4lux diff --git a/config/services.yml b/config/services.yml index 605250bb..03e027f2 100644 --- a/config/services.yml +++ b/config/services.yml @@ -37,6 +37,24 @@ services: - "@prestashop.adapter.group.provider.default_groups_provider" - "@hashing" + PrestaShop\Module\Psgdpr\Service\DataRetention\DataRetentionService: + class: 'PrestaShop\Module\Psgdpr\Service\DataRetention\DataRetentionService' + # Public so the module can resolve it from the actionCronJob hook. + public: true + arguments: + - "@Psgdpr" + - '@=service("prestashop.adapter.legacy.context").getContext()' + - '@PrestaShop\Module\Psgdpr\Repository\CustomerRepository' + - '@PrestaShop\Module\Psgdpr\Service\CustomerService' + - '@PrestaShop\Module\Psgdpr\Service\LoggerService' + + PrestaShop\Module\Psgdpr\Command\DataRetentionCommand: + class: 'PrestaShop\Module\Psgdpr\Command\DataRetentionCommand' + arguments: + - '@PrestaShop\Module\Psgdpr\Service\DataRetention\DataRetentionService' + tags: + - { name: 'console.command' } + PrestaShop\Module\Psgdpr\Service\Export\Strategy\ExportToCsv: class: 'PrestaShop\Module\Psgdpr\Service\Export\Strategy\ExportToCsv' tags: ["psgdpr.export.customerData"] diff --git a/mails/en/retention_warning.html b/mails/en/retention_warning.html new file mode 100644 index 00000000..789206fc --- /dev/null +++ b/mails/en/retention_warning.html @@ -0,0 +1,52 @@ + + + + + + {shop_name} + + + + + + +
+ + + + + + + + + + + + + + + + +
+ {shop_name} +
+

Your account is about to be anonymized

+

Hello {firstname} {lastname},

+

+ You have not signed in to your account at {shop_name} for a long time. In line with data-protection rules (GDPR), we automatically anonymize the personal data of inactive customers. +

+

+ If you wish to keep your account active, simply sign in again by clicking the button below. Otherwise, your personal data will be anonymized soon. +

+
+ Keep my account active +
+

+ Your orders and invoices are kept for the legal retention period required by law; only data identifying you personally is anonymized. +

+
+ {shop_name} +
+
+ + diff --git a/mails/en/retention_warning.txt b/mails/en/retention_warning.txt new file mode 100644 index 00000000..f52d2202 --- /dev/null +++ b/mails/en/retention_warning.txt @@ -0,0 +1,14 @@ +Your account is about to be anonymized + +Hello {firstname} {lastname}, + +You have not signed in to your account at {shop_name} for a long time. In line with data-protection rules (GDPR), we automatically anonymize the personal data of inactive customers. + +If you wish to keep your account active, simply sign in again here: +{reconnect_url} + +Otherwise, your personal data will be anonymized soon. + +Your orders and invoices are kept for the legal retention period required by law; only data identifying you personally is anonymized. + +{shop_name} - {shop_url} diff --git a/mails/fr/retention_warning.html b/mails/fr/retention_warning.html new file mode 100644 index 00000000..e6763451 --- /dev/null +++ b/mails/fr/retention_warning.html @@ -0,0 +1,52 @@ + + + + + + {shop_name} + + + + + + +
+ + + + + + + + + + + + + + + + +
+ {shop_name} +
+

Votre compte va bientôt être anonymisé

+

Bonjour {firstname} {lastname},

+

+ Vous ne vous êtes pas connecté à votre compte sur {shop_name} depuis longtemps. Conformément à la réglementation sur la protection des données (RGPD), nous anonymisons automatiquement les données personnelles des clients inactifs. +

+

+ Si vous souhaitez conserver votre compte actif, il vous suffit de vous reconnecter en cliquant sur le bouton ci-dessous. Sans action de votre part, vos données personnelles seront prochainement anonymisées. +

+
+ Conserver mon compte actif +
+

+ Vos commandes et factures sont conservées pendant la durée légale imposée par la loi ; seules les données permettant de vous identifier personnellement sont anonymisées. +

+
+ {shop_name} +
+
+ + diff --git a/mails/fr/retention_warning.txt b/mails/fr/retention_warning.txt new file mode 100644 index 00000000..5677ca82 --- /dev/null +++ b/mails/fr/retention_warning.txt @@ -0,0 +1,14 @@ +Votre compte va bientôt être anonymisé + +Bonjour {firstname} {lastname}, + +Vous ne vous êtes pas connecté à votre compte sur {shop_name} depuis longtemps. Conformément à la réglementation sur la protection des données (RGPD), nous anonymisons automatiquement les données personnelles des clients inactifs. + +Si vous souhaitez conserver votre compte actif, il vous suffit de vous reconnecter ici : +{reconnect_url} + +Sans action de votre part, vos données personnelles seront prochainement anonymisées. + +Vos commandes et factures sont conservées pendant la durée légale imposée par la loi ; seules les données permettant de vous identifier personnellement sont anonymisées. + +{shop_name} - {shop_url} diff --git a/psgdpr.php b/psgdpr.php index 9d82a284..612d7802 100755 --- a/psgdpr.php +++ b/psgdpr.php @@ -23,6 +23,7 @@ use PrestaShop\Module\Psgdpr\Entity\PsgdprConsentLang; use PrestaShop\Module\Psgdpr\Repository\ConsentRepository; use PrestaShop\Module\Psgdpr\Repository\LoggerRepository; +use PrestaShop\Module\Psgdpr\Service\DataRetention\DataRetentionService; use PrestaShop\Module\Psgdpr\Service\LoggerService; use PrestaShop\PrestaShop\Adapter\LegacyLogger; use PrestaShopBundle\Entity\Lang; @@ -59,6 +60,7 @@ class Psgdpr extends Module 'actionAdminControllerSetMedia', 'additionalCustomerFormFields', 'actionCustomerAccountAdd', + 'actionCronJob', ]; private $presetMessageAccountCreation = [ @@ -153,6 +155,12 @@ public function install(): bool $this->registerHook($this->hooksUsedByModule); $this->executeQuerySql(self::SQL_QUERY_TYPE_UNINSTALL); $this->executeQuerySql(self::SQL_QUERY_TYPE_INSTALL); + + // Data-retention defaults. Disabled by default: nothing is ever + // anonymized automatically until the merchant/DPO enables it. + Configuration::updateValue(DataRetentionService::CONFIG_ENABLED, 0); + Configuration::updateValue(DataRetentionService::CONFIG_INACTIVITY_DAYS, 1095); + Configuration::updateValue(DataRetentionService::CONFIG_WARN_DAYS, 30); } catch (PrestaShopException $e) { /** @var LegacyLogger $legacyLogger */ $legacyLogger = $this->get('prestashop.adapter.legacy.logger'); @@ -178,6 +186,10 @@ public function uninstall(): bool Configuration::deleteByName($value); } + Configuration::deleteByName(DataRetentionService::CONFIG_ENABLED); + Configuration::deleteByName(DataRetentionService::CONFIG_INACTIVITY_DAYS); + Configuration::deleteByName(DataRetentionService::CONFIG_WARN_DAYS); + parent::uninstall(); $this->executeQuerySql(self::SQL_QUERY_TYPE_UNINSTALL); } catch (PrestaShopException $e) { @@ -194,6 +206,19 @@ public function uninstall(): bool return empty($this->_errors); } + /** + * Triggered by the ps_cronjobs module (or a system cron calling it). + * Enforces the configured data-retention policy. No-op while disabled. + * + * @return void + */ + public function hookActionCronJob(): void + { + /** @var DataRetentionService $retentionService */ + $retentionService = $this->get('PrestaShop\Module\Psgdpr\Service\DataRetention\DataRetentionService'); + $retentionService->processRetention(); + } + /** * @return array */ @@ -333,6 +358,9 @@ public function getContent() 'currentPage' => $currentPage, 'ps_base_dir' => Tools::getHttpHost(true), 'ps_version' => _PS_VERSION_, + 'retention_enabled' => (bool) Configuration::get(DataRetentionService::CONFIG_ENABLED), + 'retention_inactivity_days' => (int) Configuration::get(DataRetentionService::CONFIG_INACTIVITY_DAYS), + 'retention_warn_days' => (int) Configuration::get(DataRetentionService::CONFIG_WARN_DAYS), ]); $this->output .= $this->context->smarty->fetch($this->local_path . 'views/templates/admin/menu.tpl'); @@ -404,6 +432,41 @@ public function loadAssets(): void public function postProcess() { $this->submitDataConsent(); + $this->submitDataRetention(); + } + + /** + * Save the data-retention settings from the "Data retention" tab. + * + * @return void + */ + private function submitDataRetention() + { + if (!Tools::isSubmit('submitDataRetention')) { + return; + } + + $enabled = (int) (bool) Tools::getValue(DataRetentionService::CONFIG_ENABLED); + $inactivityDays = (int) Tools::getValue(DataRetentionService::CONFIG_INACTIVITY_DAYS); + $warnDays = (int) Tools::getValue(DataRetentionService::CONFIG_WARN_DAYS); + + if ($inactivityDays < 1) { + $this->output .= $this->displayError($this->getTranslator()->trans('The inactivity period must be at least 1 day.', [], 'Modules.Psgdpr.Admin')); + + return; + } + + if ($warnDays < 0 || $warnDays >= $inactivityDays) { + $this->output .= $this->displayError($this->getTranslator()->trans('The warning lead time must be 0 or more, and shorter than the inactivity period.', [], 'Modules.Psgdpr.Admin')); + + return; + } + + Configuration::updateValue(DataRetentionService::CONFIG_ENABLED, $enabled); + Configuration::updateValue(DataRetentionService::CONFIG_INACTIVITY_DAYS, $inactivityDays); + Configuration::updateValue(DataRetentionService::CONFIG_WARN_DAYS, $warnDays); + + $this->output .= $this->displayConfirmation($this->getTranslator()->trans('Saved with success !', [], 'Modules.Psgdpr.Shop')); } /** diff --git a/src/Command/DataRetentionCommand.php b/src/Command/DataRetentionCommand.php new file mode 100644 index 00000000..28662810 --- /dev/null +++ b/src/Command/DataRetentionCommand.php @@ -0,0 +1,101 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\Psgdpr\Command; + +use PrestaShop\Module\Psgdpr\Service\DataRetention\DataRetentionService; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Runs the GDPR data-retention job from the CLI: + * php bin/console psgdpr:data-retention:run + * + * The CLI kernel exposes the full container (incl. the command bus used for + * anonymization), so this is the recommended way to schedule the job from a + * system crontab, with no dependency on the ps_cronjobs module. + */ +class DataRetentionCommand extends Command +{ + /** + * @var string + */ + protected static $defaultName = 'psgdpr:data-retention:run'; + + /** + * @var DataRetentionService + */ + private $dataRetentionService; + + /** + * @param DataRetentionService $dataRetentionService + */ + public function __construct(DataRetentionService $dataRetentionService) + { + parent::__construct(); + $this->dataRetentionService = $dataRetentionService; + } + + protected function configure() + { + $this + ->setName('psgdpr:data-retention:run') + ->setDescription('Warn and anonymize customers inactive beyond the configured retention period.') + ->addOption( + 'dry-run', + null, + InputOption::VALUE_NONE, + 'Preview only: report how many customers would be warned/anonymized, without sending any email or anonymizing any data.' + ); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $dryRun = (bool) $input->getOption('dry-run'); + + $result = $this->dataRetentionService->processRetention($dryRun); + + if ($dryRun) { + $output->writeln(sprintf( + '[DRY RUN] Would warn %d customer(s) and anonymize %d customer(s). Nothing was sent or changed.', + $result['warned'], + $result['anonymized'] + )); + + return 0; + } + + $output->writeln(sprintf( + 'Data retention: %d customer(s) warned, %d customer(s) anonymized.', + $result['warned'], + $result['anonymized'] + )); + + return 0; + } +} diff --git a/src/Entity/PsgdprLog.php b/src/Entity/PsgdprLog.php index ca5f14e2..7b9ee024 100644 --- a/src/Entity/PsgdprLog.php +++ b/src/Entity/PsgdprLog.php @@ -272,6 +272,8 @@ private function assertRequestTypeIsValid(int $requestType): void LoggerService::REQUEST_TYPE_EXPORT_PDF, LoggerService::REQUEST_TYPE_CONSENT_COLLECTING, LoggerService::REQUEST_TYPE_DELETE, + LoggerService::REQUEST_TYPE_RETENTION_WARNING, + LoggerService::REQUEST_TYPE_SCHEDULED_ANONYMIZATION, ]; if (!in_array($requestType, $validTypes)) { diff --git a/src/Repository/CustomerRepository.php b/src/Repository/CustomerRepository.php index dd7b9ca2..ebfc0dee 100644 --- a/src/Repository/CustomerRepository.php +++ b/src/Repository/CustomerRepository.php @@ -22,6 +22,7 @@ use Doctrine\DBAL\Connection; use Doctrine\ORM\Query\Expr; +use PrestaShop\Module\Psgdpr\Service\LoggerService; use PrestaShop\PrestaShop\Core\Domain\Customer\ValueObject\CustomerId; class CustomerRepository @@ -91,4 +92,75 @@ public function findCustomerIdByEmail(string $email) return false; } + + /** + * Find customers inactive beyond the retention period. + * + * "Inactive" = the most recent of (last connection / profile update) and + * (last VALID order) is older than $inactivityDays. The anonymous customer + * and soft-deleted customers are excluded. + * + * @param int $inactivityDays retention period, in days + * @param int $limit safety cap on rows returned per run + * + * @return array + * + * @todo Multistore: this scans all shops. Scope by id_shop if the retention + * policy must differ per shop. + */ + public function findInactiveCustomers(int $inactivityDays, int $limit): array + { + $cutoff = date('Y-m-d H:i:s', strtotime('-' . $inactivityDays . ' days')); + + $qb = $this->connection->createQueryBuilder(); + + $query = $qb->select('c.id_customer', 'c.id_lang', 'c.email', 'c.firstname', 'c.lastname') + ->from(_DB_PREFIX_ . 'customer', 'c') + ->where('c.deleted = 0') + ->andWhere('c.email != :anonymous_email') + ->andWhere( + 'GREATEST(' + . 'c.date_upd, ' + . 'COALESCE((SELECT MAX(o.date_add) FROM ' . _DB_PREFIX_ . 'orders o ' + . 'WHERE o.id_customer = c.id_customer AND o.valid = 1), c.date_add)' + . ') < :cutoff' + ) + ->setParameter('anonymous_email', 'anonymous@psgdpr.com') + ->setParameter('cutoff', $cutoff) + ->setMaxResults($limit) + ; + + $result = $query->execute(); + + return $result->fetchAllAssociative(); + } + + /** + * Return the date of the last retention warning logged for a customer, or null. + * Used to enforce the grace window between warning and anonymization. + * + * @param int $customerId + * @param int $moduleId + * + * @return string|null datetime string, or null if never warned + */ + public function findLastRetentionWarningDate(int $customerId, int $moduleId) + { + $qb = $this->connection->createQueryBuilder(); + + $query = $qb->select('MAX(l.date_add)') + ->from(_DB_PREFIX_ . 'psgdpr_log', 'l') + ->where('l.id_customer = :id_customer') + ->andWhere('l.id_module = :id_module') + ->andWhere('l.request_type = :request_type') + ->setParameter('id_customer', $customerId) + ->setParameter('id_module', $moduleId) + ->setParameter('request_type', LoggerService::REQUEST_TYPE_RETENTION_WARNING) + ; + + $result = $query->execute(); + $data = $result->fetchOne(); + + return $data !== false && $data !== null ? (string) $data : null; + } } diff --git a/src/Service/DataRetention/DataRetentionService.php b/src/Service/DataRetention/DataRetentionService.php new file mode 100644 index 00000000..b35923e0 --- /dev/null +++ b/src/Service/DataRetention/DataRetentionService.php @@ -0,0 +1,278 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\Psgdpr\Service\DataRetention; + +use Configuration; +use Context; +use Language; +use Mail; +use PrestaShop\Module\Psgdpr\Repository\CustomerRepository; +use PrestaShop\Module\Psgdpr\Service\CustomerService; +use PrestaShop\Module\Psgdpr\Service\LoggerService; +use PrestaShop\PrestaShop\Core\Domain\Customer\ValueObject\CustomerId; +use Psgdpr; +use Validate; + +/** + * Enforces the GDPR storage-limitation principle (art. 5.1.e) by anonymizing + * customers inactive beyond a merchant-configured retention period. + * + * Disabled by default. The merchant/DPO sets the period; nothing is ever + * deleted automatically unless explicitly enabled. + * + * Legal model (CNIL): active base -> intermediate archive -> deletion. + * We anonymize the PERSON (PII) while the underlying orders/invoices are + * preserved by the existing CustomerService anonymization primitive, so + * accounting/tax retention duties (Code de commerce art. L123-22: 10 years, + * LPF art. L102 B: 6 years) are respected. + * + * "Inactive" = the most recent of (last connection, last valid order) is older + * than the configured period. + */ +class DataRetentionService +{ + const CONFIG_ENABLED = 'PSGDPR_RETENTION_ENABLED'; + const CONFIG_INACTIVITY_DAYS = 'PSGDPR_RETENTION_INACTIVITY_DAYS'; + const CONFIG_WARN_DAYS = 'PSGDPR_RETENTION_WARN_DAYS'; + + /** + * Safety cap: max customers processed per cron run, to stay safe on large bases. + */ + const BATCH_LIMIT = 200; + + /** + * @var Psgdpr + */ + private $module; + + /** + * @var Context + */ + private $context; + + /** + * @var CustomerRepository + */ + private $customerRepository; + + /** + * @var CustomerService + */ + private $customerService; + + /** + * @var LoggerService + */ + private $loggerService; + + /** + * @param Psgdpr $module + * @param Context $context + * @param CustomerRepository $customerRepository + * @param CustomerService $customerService + * @param LoggerService $loggerService + */ + public function __construct( + Psgdpr $module, + Context $context, + CustomerRepository $customerRepository, + CustomerService $customerService, + LoggerService $loggerService + ) { + $this->module = $module; + $this->context = $context; + $this->customerRepository = $customerRepository; + $this->customerService = $customerService; + $this->loggerService = $loggerService; + } + + /** + * Cron entry point. Runs the two-phase retention job: + * 1. warn customers about to be anonymized (grace period), + * 2. anonymize those warned long enough ago and still inactive. + * + * @return array{warned: int, anonymized: int} counters for logging/monitoring + */ + public function processRetention(bool $dryRun = false): array + { + $result = ['warned' => 0, 'anonymized' => 0, 'dry_run' => $dryRun]; + + // A live run respects the enable switch; a dry run always previews, + // sending nothing and deleting nothing. + if (!$dryRun && (bool) Configuration::get(self::CONFIG_ENABLED) === false) { + return $result; + } + + $inactivityDays = (int) Configuration::get(self::CONFIG_INACTIVITY_DAYS); + $warnDays = (int) Configuration::get(self::CONFIG_WARN_DAYS); + + // Misconfiguration guard: never run with a zero/negative period. + if ($inactivityDays <= 0) { + return $result; + } + + $result['anonymized'] = $this->anonymizeWarnedCustomers($inactivityDays, $warnDays, $dryRun); + $result['warned'] = $this->warnInactiveCustomers($inactivityDays, $warnDays, $dryRun); + + return $result; + } + + /** + * Phase 1 — send a warning email (with a reconnection link) to inactive + * customers who have not already been warned within the current grace window. + * Logging back in updates the customer's last activity and removes them from + * the next run's selection. + * + * @param int $inactivityDays + * @param int $warnDays + * @param bool $dryRun when true, count candidates only — send no email, write no log + * + * @return int number of customers warned (or that would be warned, in dry run) + */ + private function warnInactiveCustomers(int $inactivityDays, int $warnDays, bool $dryRun): int + { + $warned = 0; + $candidates = $this->customerRepository->findInactiveCustomers($inactivityDays, self::BATCH_LIMIT); + + foreach ($candidates as $customer) { + $customerId = (int) $customer['id_customer']; + $lastWarning = $this->customerRepository->findLastRetentionWarningDate($customerId, (int) $this->module->id); + + // Already warned and still inside the grace window: leave it for phase 2. + if ($lastWarning !== null && strtotime($lastWarning) > strtotime('-' . $warnDays . ' days')) { + continue; + } + + if ($dryRun) { + ++$warned; + continue; + } + + if ($this->sendWarningEmail($customer) === false) { + continue; + } + + $this->loggerService->createLog( + $customerId, + LoggerService::REQUEST_TYPE_RETENTION_WARNING, + (int) $this->module->id, + 0, + (string) $customer['email'] + ); + ++$warned; + } + + return $warned; + } + + /** + * Phase 2 — anonymize customers who were warned at least $warnDays ago and + * are still inactive. Reuses the module's existing anonymization primitive, + * which preserves orders/invoices for legal retention. + * + * @param int $inactivityDays + * @param int $warnDays + * @param bool $dryRun when true, count candidates only — anonymize nothing, write no log + * + * @return int number of customers anonymized (or that would be, in dry run) + */ + private function anonymizeWarnedCustomers(int $inactivityDays, int $warnDays, bool $dryRun): int + { + $anonymized = 0; + $candidates = $this->customerRepository->findInactiveCustomers($inactivityDays, self::BATCH_LIMIT); + + foreach ($candidates as $customer) { + $customerId = (int) $customer['id_customer']; + $lastWarning = $this->customerRepository->findLastRetentionWarningDate($customerId, (int) $this->module->id); + + // Only anonymize once a warning has been sent and the grace window has elapsed. + if ($lastWarning === null || strtotime($lastWarning) > strtotime('-' . $warnDays . ' days')) { + continue; + } + + if ($dryRun) { + ++$anonymized; + continue; + } + + $this->customerService->deleteCustomerDataFromPrestashop(new CustomerId($customerId)); + $this->customerService->deleteCustomerDataFromModules((string) $customer['email']); + + $this->loggerService->createLog( + $customerId, + LoggerService::REQUEST_TYPE_SCHEDULED_ANONYMIZATION, + (int) $this->module->id, + 0, + 'retention:' . $inactivityDays . 'd' + ); + ++$anonymized; + } + + return $anonymized; + } + + /** + * Send the pre-anonymization warning email. + * + * @param array $customer associative row from findInactiveCustomers() + * + * @return bool whether the mail was accepted for sending + * + * @todo Add the `retention_warning` mail templates under mails// + * (html + txt) before enabling in production. + */ + private function sendWarningEmail(array $customer): bool + { + // Send in the customer's own language; fall back to the context language. + $languageId = isset($customer['id_lang']) ? (int) $customer['id_lang'] : 0; + if ($languageId <= 0) { + $languageId = (int) $this->context->language->id; + } + + $reconnectUrl = $this->context->link->getPageLink('my-account', true); + + // Localize the subject in the customer's language (body is localized via + // the per-iso mail template directory). + $locale = null; + $lang = new Language($languageId); + if (Validate::isLoadedObject($lang)) { + $locale = $lang->locale; + } + + return (bool) Mail::Send( + $languageId, + 'retention_warning', + $this->module->getTranslator()->trans('Your account is about to be anonymized', [], 'Modules.Psgdpr.Email', $locale), + [ + '{firstname}' => (string) $customer['firstname'], + '{lastname}' => (string) $customer['lastname'], + '{reconnect_url}' => $reconnectUrl, + ], + (string) $customer['email'], + trim($customer['firstname'] . ' ' . $customer['lastname']), + null, + null, + null, + null, + _PS_MODULE_DIR_ . $this->module->name . '/mails/' + ); + } +} diff --git a/src/Service/LoggerService.php b/src/Service/LoggerService.php index 6bbe5da0..a4c958e8 100644 --- a/src/Service/LoggerService.php +++ b/src/Service/LoggerService.php @@ -31,6 +31,8 @@ class LoggerService const REQUEST_TYPE_EXPORT_PDF = 2; const REQUEST_TYPE_EXPORT_CSV = 3; const REQUEST_TYPE_DELETE = 4; + const REQUEST_TYPE_RETENTION_WARNING = 5; + const REQUEST_TYPE_SCHEDULED_ANONYMIZATION = 6; /** * @var LoggerRepository diff --git a/views/templates/admin/menu.tpl b/views/templates/admin/menu.tpl index cc83c9e0..b421f838 100644 --- a/views/templates/admin/menu.tpl +++ b/views/templates/admin/menu.tpl @@ -25,6 +25,7 @@ {l s='Personal data management' d='Modules.Psgdpr.Admin'} {l s='Consent checkbox customization' d='Modules.Psgdpr.Admin'} {l s='Customer activity tracking' d='Modules.Psgdpr.Admin'} + {l s='Data retention' d='Modules.Psgdpr.Admin'} {l s='Help' d='Modules.Psgdpr.Admin'}
@@ -50,6 +51,10 @@ {include file="./tabs/customerActivity.tpl"}
+
+ {include file="./tabs/dataRetention.tpl"} +
+
{include file="./tabs/help.tpl"}
diff --git a/views/templates/admin/tabs/dataRetention.tpl b/views/templates/admin/tabs/dataRetention.tpl new file mode 100644 index 00000000..ce386fdc --- /dev/null +++ b/views/templates/admin/tabs/dataRetention.tpl @@ -0,0 +1,103 @@ +{** + * Copyright since 2007 PrestaShop SA and Contributors + * PrestaShop is an International Registered Trademark & Property of PrestaShop SA + * + * NOTICE OF LICENSE + * + * This source file is subject to the Academic Free License 3.0 (AFL-3.0) + * that is bundled with this package in the file LICENSE.md. + * It is also available through the world-wide-web at this URL: + * https://opensource.org/licenses/AFL-3.0 + * If you did not receive a copy of the license and are unable to + * obtain it through the world-wide-web, please send an email + * to license@prestashop.com so we can send you a copy immediately. + * + * @author PrestaShop SA + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License 3.0 (AFL-3.0) + *} +
+

+ {l s='Data retention' d='Modules.Psgdpr.Admin'} {$module_display|escape:'htmlall':'UTF-8'} +

+ +

{l s='Automatically anonymize customers who have been inactive for too long, to comply with the GDPR storage-limitation principle (art. 5.1.e).' d='Modules.Psgdpr.Admin'}

+

{l s='A customer is considered inactive when both his last connection and his last valid order are older than the period below.' d='Modules.Psgdpr.Admin'}

+ + + +
+ +
+ +
+ + + + + + + +

{l s='Disabled by default. Nothing is ever anonymized while this is off.' d='Modules.Psgdpr.Admin'}

+
+
+ +
+ +
+ +
+
+

{l s='Recommended: 1095 days (3 years) for inactive customers, per CNIL guidance. Confirm the right duration with your DPO.' d='Modules.Psgdpr.Admin'}

+
+
+ +
+ +
+ +
+
+

{l s='How long before anonymization the warning email is sent. Must be shorter than the inactivity period.' d='Modules.Psgdpr.Admin'}

+
+
+ + +
+ +
+ +

+ {l s='Scheduling' d='Modules.Psgdpr.Admin'} +

+

{l s='PrestaShop has no built-in scheduler. Run the retention job from your server crontab with the console command below (it runs with the full PrestaShop container, including anonymization).' d='Modules.Psgdpr.Admin'}

+

{l s='If the ps_cronjobs module is installed, the job also runs via its cron hook.' d='Modules.Psgdpr.Admin'}

+ +
+ +
+ +

+ {l s='Example crontab entry (daily at 3am):' d='Modules.Psgdpr.Admin'} +
+ 0 3 * * * cd /path/to/your/shop && php bin/console psgdpr:data-retention:run +

+
+
+