value. */ const EXPORT_FIELDS_FILTER = 'careback_privacy_export_fields'; /** * Filter: the columns of the requests table the export accounts for * ( string[] $columns, array $row, Settings $settings ) — exported through * the fields above, or not personal data. The free part's own columns, and * those an add-on adds to the table and accounts for. Any other column a * stored row carries is exported as it is, under its own name. */ const EXPORT_COLUMNS_FILTER = 'careback_privacy_export_columns'; private $requests; private $settings; /** * @param Settings|null $settings Omitted, the stored settings are read. */ public function __construct( Requests_Repository $requests, ?Settings $settings = null ) { $this->requests = $requests; $this->settings = $settings instanceof Settings ? $settings : new Settings(); } /** An editable text of the export, which the data subject reads. */ private function label( $key ) { return Texts::get( $key, $this->settings ); } public function init() { add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'register_exporter' ) ); add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_eraser' ) ); add_action( 'admin_init', array( $this, 'add_policy_content' ) ); } /** * Hands the suggested privacy policy text to WordPress's policy guide * (Settings → Privacy → Policy guide). WordPress takes it on admin_init, in * wp-admin only. * * @return void */ public function add_policy_content() { if ( ! is_admin() || ! function_exists( 'wp_add_privacy_policy_content' ) ) { return; } wp_add_privacy_policy_content( 'Careback', $this->policy_text() ); } /** * The suggested privacy policy text, for these settings: what a request * stores and for how long, and what the protection of the form keeps. It * describes what the plugin does; it promises nothing. * * @return string HTML. */ public function policy_text() { $years = max( 1, (int) $this->settings->get( 'retention_extra_years', 3 ) ); $days = max( 1, (int) $this->settings->get( 'retention_events_days', 90 ) ); $minutes = max( 60, (int) $this->settings->get( 'lookup_window_minutes', 15 ), (int) $this->settings->get( 'track_window_minutes', 15 ), (int) $this->settings->get( 'ban_minutes', 60 ) ); $html = '

' . esc_html__( 'Careback stores the data below when customers use your returns form and your order tracking page. Check that it matches your settings and how your shop works before you add it to your privacy policy.', 'careback' ) . '

'; $html .= '

' . esc_html__( 'Suggested text:', 'careback' ) . ' ' . esc_html__( 'When you send us a withdrawal or return request through our returns form, we store your order number, your name, the items you send back, the reason and comment you give, the exact time the request reached us (in UTC) and the browser you used (its user agent).', 'careback' ) . ' ' . esc_html__( 'We do not store your email address or IP address as they are, only a coded fingerprint of each, so that we can find your requests again, for example when you ask us for a copy of your data. We also add a note about each request to your order.', 'careback' ) . '

'; $html .= '

' . esc_html( sprintf( /* translators: %d: number of years. */ _n( 'We delete your name, your comment, the browser details and the fingerprints %d year after your request is closed, or after it was sent if it is still open then.', 'We delete your name, your comment, the browser details and the fingerprints %d years after your request is closed, or after it was sent if it is still open then.', $years, 'careback' ), $years ) ) . ' ' . esc_html__( 'The rest of the request (order number, items, dates and status) stays with your order as part of our business records. If you ask us to erase your data, we remove these details and our notes on the request at once and keep the business record.', 'careback' ) . '

'; $html .= '

' . esc_html( sprintf( /* translators: %d: number of days. */ _n( 'To protect the returns form and the tracking page from misuse, we log failed and blocked attempts with a fingerprint of the IP address and the time, and delete these entries after %d day.', 'To protect the returns form and the tracking page from misuse, we log failed and blocked attempts with a fingerprint of the IP address and the time, and delete these entries after %d days.', $days, 'careback' ), $days ) ) . ' ' . esc_html( sprintf( /* translators: %d: number of minutes. */ _n( 'Counters of recent attempts hold only fingerprints and expire on their own within %d minute.', 'Counters of recent attempts hold only fingerprints and expire on their own within %d minutes.', $minutes, 'careback' ), $minutes ) ) . '

'; // Only while the form asks for the consent with the returns policy (N-12). if ( ! empty( $this->settings->get( Returns_Policy::CONSENT, 0 ) ) ) { $html .= '

' . esc_html__( 'When you accept our returns policy in the returns form, we record that you accepted it, with the page and the date it last changed. This record stays with the rest of the request; if you ask us to erase your data, it goes with our notes on the request.', 'careback' ) . '

'; } return $html; } public function register_exporter( $exporters ) { $exporters['careback'] = array( 'exporter_friendly_name' => _x( 'Careback requests', 'admin privacy tool name', 'careback' ), 'callback' => array( $this, 'export' ), ); return $exporters; } public function register_eraser( $erasers ) { $erasers['careback'] = array( 'eraser_friendly_name' => _x( 'Careback requests', 'admin privacy tool name', 'careback' ), 'callback' => array( $this, 'erase' ), ); return $erasers; } public function export( $email, $page = 1 ) { $export = array(); foreach ( $this->requests->for_email( $email ) as $row ) { $items = array(); foreach ( (array) json_decode( (string) $row['items'], true ) as $item ) { $items[] = $item['name'] . ' x' . $item['quantity']; } $fields = array( 'privacy_reference' => $row['id'], 'privacy_order' => $row['order_number'], 'privacy_name' => $row['customer_name'], 'privacy_items' => implode( ', ', $items ), 'privacy_reason' => (string) $row['reason'], 'privacy_comment' => (string) $row['comment'], 'privacy_status' => $row['status'], 'privacy_browser' => isset( $row['user_agent'] ) ? (string) $row['user_agent'] : '', 'privacy_created' => $row['created_at'], 'privacy_resolution' => isset( $row['resolution'] ) ? (string) $row['resolution'] : '', 'privacy_resolution_note' => isset( $row['resolution_note'] ) ? (string) $row['resolution_note'] : '', 'privacy_consent_quote' => isset( $row['consent_quote'] ) ? (string) $row['consent_quote'] : '', ); // The consent with the returns policy given on the form (N-12), on a // request filed with it: which page, and when it had last changed. $policy = self::policy_consent( $row ); if ( $policy ) { $fields['privacy_policy_accepted'] = Texts::format( 'privacy_policy_page', $this->settings, array( isset( $policy['note'] ) ? (string) $policy['note'] : '', isset( $policy['changed'] ) ? (string) $policy['changed'] : '', ) ); } // An add-on discloses what it stores on the row in the same copy of the // file: each field named by a text the data subject reads. $fields = (array) apply_filters( self::EXPORT_FIELDS_FILTER, $fields, $row, $this->settings ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_privacy_export_fields, the final prefix. $data = array(); foreach ( $fields as $key => $value ) { $data[] = array( 'name' => $this->label( (string) $key ), 'value' => $value, ); } // Whatever else the row stores: columns an add-on added to the table // and nobody accounts for now — the add-on is gone, its data is not. // Every stored datum belongs in the subject's copy, so each one that // holds something is exported under its column name. $accounted = (array) apply_filters( self::EXPORT_COLUMNS_FILTER, Database::columns( 'requests' ), $row, $this->settings ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- careback_privacy_export_columns, the final prefix. foreach ( $row as $column => $value ) { if ( in_array( (string) $column, $accounted, true ) || self::is_blank( $value ) ) { continue; } $data[] = array( 'name' => (string) $column, 'value' => (string) $value, ); } $export[] = array( 'group_id' => 'careback', 'group_label' => $this->label( 'privacy_group' ), 'item_id' => 'careback-' . (int) $row['id'], 'data' => $data, ); } return array( 'data' => $export, 'done' => true, ); } /** * The history entry recording the consent with the returns policy, or null. * * @param array $row Request row. * @return array|null */ private static function policy_consent( array $row ) { $found = null; foreach ( (array) json_decode( isset( $row['history'] ) ? (string) $row['history'] : '', true ) as $event ) { if ( is_array( $event ) && isset( $event['kind'] ) && Returns_Policy::HISTORY_KIND === $event['kind'] ) { $found = $event; } } return $found; } /** * True for a stored value that holds nothing: NULL, empty, a zero number or * the zero date — a column default, not a datum. * * @param mixed $value Stored value. * @return bool */ private static function is_blank( $value ) { if ( null === $value ) { return true; } $value = trim( (string) $value ); return '' === $value || '0000-00-00 00:00:00' === $value || ( is_numeric( $value ) && 0.0 === (float) $value ); } /** * The commercial record stays with the order for the statutory retention * period, so the request row is anonymised rather than deleted. */ public function erase( $email, $page = 1 ) { $removed = false; foreach ( $this->requests->for_email( $email ) as $row ) { $this->requests->anonymise( (int) $row['id'] ); $removed = true; } return array( 'items_removed' => false, 'items_retained' => $removed, 'messages' => $removed ? array( __( 'Requests were anonymised; the commercial record is kept with the order for the statutory retention period.', 'careback' ) ) : array(), 'done' => true, ); } }