monorepo/native/wordpress/maple-carts-wp/includes/class-maple-carts-privacy.php

502 lines
16 KiB
PHP

<?php
/**
* GDPR Privacy Handler for Maple Carts.
*
* Integrates with WordPress and WooCommerce privacy tools.
*
* @package MapleCarts
*/
defined( 'ABSPATH' ) || exit;
/**
* Privacy class.
*/
class Maple_Carts_Privacy {
/**
* Single instance.
*
* @var Maple_Carts_Privacy
*/
private static $instance = null;
/**
* Get instance.
*
* @return Maple_Carts_Privacy
*/
public static function instance() {
if ( is_null( self::$instance ) ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
*/
private function __construct() {
// WordPress Privacy hooks.
add_action( 'admin_init', [ $this, 'add_privacy_policy_content' ] );
add_filter( 'wp_privacy_personal_data_exporters', [ $this, 'register_data_exporter' ] );
add_filter( 'wp_privacy_personal_data_erasers', [ $this, 'register_data_eraser' ] );
// Checkout consent checkbox.
if ( 'yes' === Maple_Carts::get_option( 'require_consent', 'no' ) ) {
add_action( 'woocommerce_review_order_before_submit', [ $this, 'add_consent_checkbox' ] );
add_action( 'woocommerce_checkout_process', [ $this, 'validate_consent_checkbox' ] );
}
// Data deletion request from unsubscribe.
add_action( 'wp', [ $this, 'handle_data_deletion_request' ] );
}
/**
* Add privacy policy suggested content.
*/
public function add_privacy_policy_content() {
if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) {
return;
}
$content = $this->get_privacy_policy_content();
wp_add_privacy_policy_content( 'Maple Carts', $content );
}
/**
* Get privacy policy suggested content.
*
* @return string
*/
private function get_privacy_policy_content() {
$retention_days = Maple_Carts::get_option( 'delete_after_days', 90 );
return sprintf(
'<h2>%s</h2>
<p>%s</p>
<h3>%s</h3>
<p>%s</p>
<ul>
<li>%s</li>
<li>%s</li>
<li>%s</li>
<li>%s</li>
<li>%s</li>
<li>%s</li>
</ul>
<h3>%s</h3>
<p>%s</p>
<h3>%s</h3>
<p>%s</p>
<h3>%s</h3>
<p>%s</p>
<h3>%s</h3>
<p>%s</p>',
__( 'Abandoned Cart Recovery', 'maple-carts' ),
__( 'We use abandoned cart recovery technology to help remind you about items left in your shopping cart. This section explains what data we collect and how we use it.', 'maple-carts' ),
__( 'What data we collect', 'maple-carts' ),
__( 'When you begin the checkout process, we may collect:', 'maple-carts' ),
__( 'Email address', 'maple-carts' ),
__( 'Name and phone number', 'maple-carts' ),
__( 'Billing address', 'maple-carts' ),
__( 'Cart contents and total', 'maple-carts' ),
__( 'IP address', 'maple-carts' ),
__( 'Browser information', 'maple-carts' ),
__( 'Why we collect this data', 'maple-carts' ),
__( 'We collect this data to send you reminder emails if you leave items in your cart without completing your purchase. These emails may include a discount code to encourage you to complete your order.', 'maple-carts' ),
__( 'How long we retain your data', 'maple-carts' ),
sprintf(
/* translators: %d: number of days */
__( 'Abandoned cart data is automatically deleted after %d days. You can request immediate deletion at any time.', 'maple-carts' ),
$retention_days
),
__( 'Your rights', 'maple-carts' ),
__( 'You can unsubscribe from abandoned cart emails at any time by clicking the unsubscribe link in any email. You can also request a copy of your data or request deletion through our privacy tools.', 'maple-carts' ),
__( 'Third-party sharing', 'maple-carts' ),
__( 'We do not share your abandoned cart data with any third parties. Emails are sent through our own email system or your configured SMTP provider.', 'maple-carts' )
);
}
/**
* Register data exporter.
*
* @param array $exporters Existing exporters.
* @return array
*/
public function register_data_exporter( $exporters ) {
$exporters['maple-carts'] = [
'exporter_friendly_name' => __( 'Maple Carts Abandoned Cart Data', 'maple-carts' ),
'callback' => [ $this, 'export_personal_data' ],
];
return $exporters;
}
/**
* Register data eraser.
*
* @param array $erasers Existing erasers.
* @return array
*/
public function register_data_eraser( $erasers ) {
$erasers['maple-carts'] = [
'eraser_friendly_name' => __( 'Maple Carts Abandoned Cart Data', 'maple-carts' ),
'callback' => [ $this, 'erase_personal_data' ],
];
return $erasers;
}
/**
* Export personal data for a user.
*
* @param string $email_address Email address.
* @param int $page Page number.
* @return array
*/
public function export_personal_data( $email_address, $page = 1 ) {
global $wpdb;
$export_items = [];
$table = $wpdb->prefix . MAPLE_CARTS_TABLE;
$per_page = 10;
$offset = ( $page - 1 ) * $per_page;
$carts = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE email = %s LIMIT %d OFFSET %d",
$email_address,
$per_page,
$offset
)
);
foreach ( $carts as $cart ) {
$cart_contents = maybe_unserialize( $cart->cart_contents );
$product_names = [];
if ( is_array( $cart_contents ) ) {
foreach ( $cart_contents as $item ) {
$product_names[] = $item['name'] ?? 'Unknown Product';
}
}
$data = [
[
'name' => __( 'Email', 'maple-carts' ),
'value' => $cart->email,
],
[
'name' => __( 'Name', 'maple-carts' ),
'value' => $cart->customer_name ?: __( 'Not provided', 'maple-carts' ),
],
[
'name' => __( 'Phone', 'maple-carts' ),
'value' => $cart->customer_phone ?: __( 'Not provided', 'maple-carts' ),
],
[
'name' => __( 'Cart Contents', 'maple-carts' ),
'value' => implode( ', ', $product_names ),
],
[
'name' => __( 'Cart Total', 'maple-carts' ),
'value' => wc_price( $cart->cart_total, [ 'currency' => $cart->currency ] ),
],
[
'name' => __( 'Status', 'maple-carts' ),
'value' => ucfirst( $cart->status ),
],
[
'name' => __( 'IP Address', 'maple-carts' ),
'value' => $cart->ip_address ?: __( 'Not recorded', 'maple-carts' ),
],
[
'name' => __( 'Created', 'maple-carts' ),
'value' => $cart->created_at,
],
[
'name' => __( 'Unsubscribed', 'maple-carts' ),
'value' => $cart->unsubscribed ? __( 'Yes', 'maple-carts' ) : __( 'No', 'maple-carts' ),
],
];
// Add billing data if present.
$billing_data = maybe_unserialize( $cart->billing_data );
if ( ! empty( $billing_data ) ) {
$address_parts = array_filter( [
$billing_data['address_1'] ?? '',
$billing_data['address_2'] ?? '',
$billing_data['city'] ?? '',
$billing_data['state'] ?? '',
$billing_data['postcode'] ?? '',
$billing_data['country'] ?? '',
] );
if ( ! empty( $address_parts ) ) {
$data[] = [
'name' => __( 'Billing Address', 'maple-carts' ),
'value' => implode( ', ', $address_parts ),
];
}
}
$export_items[] = [
'group_id' => 'maple-carts',
'group_label' => __( 'Abandoned Cart Data', 'maple-carts' ),
'group_description' => __( 'Cart data collected during checkout.', 'maple-carts' ),
'item_id' => 'cart-' . $cart->id,
'data' => $data,
];
}
// Check if there are more records.
$total = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE email = %s",
$email_address
)
);
$done = ( $page * $per_page ) >= $total;
return [
'data' => $export_items,
'done' => $done,
];
}
/**
* Erase personal data for a user.
*
* @param string $email_address Email address.
* @param int $page Page number.
* @return array
*/
public function erase_personal_data( $email_address, $page = 1 ) {
global $wpdb;
$table = $wpdb->prefix . MAPLE_CARTS_TABLE;
$log_table = $wpdb->prefix . MAPLE_CARTS_EMAIL_LOG_TABLE;
$per_page = 50;
$items_removed = 0;
$items_retained = 0;
// Get carts to delete.
$carts = $wpdb->get_results(
$wpdb->prepare(
"SELECT id FROM {$table} WHERE email = %s LIMIT %d",
$email_address,
$per_page
)
);
foreach ( $carts as $cart ) {
// Delete email logs first.
$wpdb->delete( $log_table, [ 'cart_id' => $cart->id ] );
// Delete cart.
$deleted = $wpdb->delete( $table, [ 'id' => $cart->id ] );
if ( $deleted ) {
$items_removed++;
} else {
$items_retained++;
}
}
// Check if there are more records.
$remaining = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE email = %s",
$email_address
)
);
$done = ( $remaining == 0 );
return [
'items_removed' => $items_removed,
'items_retained' => $items_retained,
'messages' => [],
'done' => $done,
];
}
/**
* Add consent checkbox to checkout.
*/
public function add_consent_checkbox() {
$consent_text = Maple_Carts::get_option(
'consent_text',
__( 'I agree to receive cart reminder emails if I don\'t complete my purchase.', 'maple-carts' )
);
woocommerce_form_field(
'maple_carts_consent',
[
'type' => 'checkbox',
'class' => [ 'form-row maple-carts-consent' ],
'label' => esc_html( $consent_text ),
'required' => false,
]
);
}
/**
* Validate consent checkbox.
*/
public function validate_consent_checkbox() {
// If consent is required and not given, we don't track the cart.
// This is handled in the tracking class.
}
/**
* Check if user has given consent.
*
* @return bool
*/
public static function has_consent() {
// If consent is not required, always return true.
if ( 'yes' !== Maple_Carts::get_option( 'require_consent', 'no' ) ) {
return true;
}
// Check if consent checkbox was checked.
if ( isset( $_POST['maple_carts_consent'] ) ) {
return true;
}
// Check session for previous consent.
if ( WC()->session && WC()->session->get( 'maple_carts_consent' ) ) {
return true;
}
return false;
}
/**
* Record consent.
*/
public static function record_consent() {
if ( WC()->session ) {
WC()->session->set( 'maple_carts_consent', true );
WC()->session->set( 'maple_carts_consent_time', current_time( 'mysql' ) );
}
}
/**
* Handle data deletion request from unsubscribe page.
*/
public function handle_data_deletion_request() {
if ( ! isset( $_GET['maple_delete_data'] ) ) {
return;
}
$token = sanitize_text_field( wp_unslash( $_GET['maple_delete_data'] ) );
// Verify signed token (same as unsubscribe).
$tracking = Maple_Carts_Tracking::instance();
$data = $this->verify_deletion_token( $token );
if ( ! $data || empty( $data['sid'] ) ) {
wc_add_notice( __( 'This data deletion link has expired or is invalid.', 'maple-carts' ), 'error' );
wp_safe_redirect( wc_get_page_permalink( 'shop' ) );
exit;
}
$session_id = sanitize_text_field( $data['sid'] );
$cart = Maple_Carts_DB::get_cart_by_session( $session_id );
if ( $cart ) {
// Delete all data for this cart.
Maple_Carts_DB::delete_cart( $cart->id );
wc_add_notice( __( 'Your cart data has been permanently deleted.', 'maple-carts' ), 'success' );
}
wp_safe_redirect( wc_get_page_permalink( 'shop' ) );
exit;
}
/**
* Verify deletion token (reuses tracking token verification).
*
* @param string $token Token to verify.
* @return array|false
*/
private function verify_deletion_token( $token ) {
$decoded = base64_decode( $token, true );
if ( ! $decoded || strpos( $decoded, '|' ) === false ) {
return false;
}
$parts = explode( '|', $decoded, 2 );
if ( count( $parts ) !== 2 ) {
return false;
}
list( $payload, $signature ) = $parts;
// Verify HMAC signature.
$secret = wp_salt( 'auth' ) . 'maple_carts';
$expected_signature = hash_hmac( 'sha256', $payload, $secret );
if ( ! hash_equals( $expected_signature, $signature ) ) {
return false;
}
$data = json_decode( $payload, true );
if ( ! $data ) {
return false;
}
// Token valid for 90 days.
if ( isset( $data['t'] ) && ( time() - $data['t'] ) > ( 90 * DAY_IN_SECONDS ) ) {
return false;
}
return $data;
}
/**
* Generate data deletion URL.
*
* @param string $session_id Session ID.
* @return string
*/
public static function generate_deletion_url( $session_id ) {
$data = [
'sid' => $session_id,
't' => time(),
];
$payload = wp_json_encode( $data );
$secret = wp_salt( 'auth' ) . 'maple_carts';
$signature = hash_hmac( 'sha256', $payload, $secret );
$token = base64_encode( $payload . '|' . $signature );
return add_query_arg( 'maple_delete_data', $token, home_url() );
}
/**
* Get anonymized data (for when full deletion isn't required).
*
* @param int $cart_id Cart ID.
*/
public static function anonymize_cart( $cart_id ) {
global $wpdb;
$table = $wpdb->prefix . MAPLE_CARTS_TABLE;
$wpdb->update(
$table,
[
'email' => 'deleted@anonymized.invalid',
'customer_name' => 'Anonymized',
'customer_phone' => null,
'billing_data' => null,
'ip_address' => '0.0.0.0',
'user_agent' => null,
],
[ 'id' => $cart_id ]
);
}
}