$exporters Registered exporters. * @return array */ public function register_exporters( $exporters ) { if ( ! is_array( $exporters ) ) { $exporters = []; } $exporters['suredonation'] = [ 'exporter_friendly_name' => __( 'SureDonation Donations', 'suredonation' ), 'callback' => [ $this, 'export' ], ]; return $exporters; } /** * Register the SureDonation personal-data eraser. * * @since 1.2.0 * @param array $erasers Registered erasers. * @return array */ public function register_erasers( $erasers ) { if ( ! is_array( $erasers ) ) { $erasers = []; } $erasers['suredonation'] = [ 'eraser_friendly_name' => __( 'SureDonation Donations', 'suredonation' ), 'callback' => [ $this, 'erase' ], ]; return $erasers; } /** * Export a donor's personal data (donor profile + their donations). * * Paginated per the WordPress exporter contract: the donor profile is emitted on * the first page and donations are returned in fixed-size batches, so a donor with * many donations doesn't load them all into one response. * * @since 1.2.0 * @param string $email_address The email being exported. * @param int $page 1-based page number supplied by WordPress. * @return array{data: array>, done: bool} */ public function export( $email_address, $page = 1 ) { $email = sanitize_email( (string) $email_address ); $page = max( 1, (int) $page ); $batch = 100; $export = []; // Donor profile is emitted once, on the first page. $donor = 1 === $page ? Donors::get_by_email( $email ) : null; if ( is_array( $donor ) && ! empty( $donor ) ) { $export[] = [ 'group_id' => 'suredonation-donor', 'group_label' => __( 'SureDonation Donor', 'suredonation' ), 'item_id' => 'suredonation-donor-' . absint( Helper::get_string_value( $donor['id'] ?? 0 ) ), 'data' => self::name_value_rows( [ __( 'Name', 'suredonation' ) => $donor['name'] ?? '', __( 'Email', 'suredonation' ) => $donor['email'] ?? '', __( 'Phone', 'suredonation' ) => $donor['phone'] ?? '', __( 'Company', 'suredonation' ) => $donor['company'] ?? '', __( 'Address', 'suredonation' ) => $donor['address'] ?? '', ] ), ]; } $donations = Donations::get_by_donor_email( $email, $batch, ( $page - 1 ) * $batch ); $donations = is_array( $donations ) ? $donations : []; foreach ( $donations as $donation ) { if ( ! is_array( $donation ) ) { continue; } // Mirrors the eraser's field set — everything the eraser treats as donor // PII (incl. ip / user-agent / referrer) must also be disclosed on export. $rows = [ __( 'Amount', 'suredonation' ) => $donation['amount'] ?? '', __( 'Currency', 'suredonation' ) => $donation['currency'] ?? '', __( 'Status', 'suredonation' ) => $donation['status'] ?? '', __( 'Date', 'suredonation' ) => $donation['created_at'] ?? '', __( 'Donor Name', 'suredonation' ) => $donation['donor_name'] ?? '', __( 'Donor Email', 'suredonation' ) => $donation['donor_email'] ?? '', __( 'Donor Phone', 'suredonation' ) => $donation['donor_phone'] ?? '', __( 'Comment', 'suredonation' ) => $donation['donor_comment'] ?? '', __( 'IP Address', 'suredonation' ) => $donation['ip_address'] ?? '', __( 'User Agent', 'suredonation' ) => $donation['user_agent'] ?? '', __( 'Referrer', 'suredonation' ) => $donation['referer_url'] ?? '', ]; // Include the extra submitted form fields (label => value) if present. $fields = isset( $donation['donation_data']['fields'] ) && is_array( $donation['donation_data']['fields'] ) ? $donation['donation_data']['fields'] : []; foreach ( $fields as $field ) { if ( ! is_array( $field ) || ! isset( $field['label'] ) ) { continue; } // Deduplicate repeated labels ("Label", "Label (2)", …) — a duplicate // key would silently overwrite the earlier field's value in the export. $label = Helper::get_string_value( $field['label'] ); $unique = $label; $suffix = 2; while ( array_key_exists( $unique, $rows ) ) { $unique = $label . ' (' . $suffix . ')'; ++$suffix; } $rows[ $unique ] = $field['value'] ?? ''; } $export[] = [ 'group_id' => 'suredonation-donations', 'group_label' => __( 'SureDonation Donations', 'suredonation' ), 'item_id' => 'suredonation-donation-' . absint( Helper::get_string_value( $donation['id'] ?? 0 ) ), 'data' => self::name_value_rows( $rows ), ]; } // Done once a page returns fewer donations than the batch size. $done = count( $donations ) < $batch; if ( $done ) { /** * Fires when a personal-data export including SureDonation data completes. * * @since 1.2.0 */ do_action( 'suredonation_privacy_data_exported' ); } return [ 'data' => $export, 'done' => $done, ]; } /** * Erase a donor's personal data, honoring the retention period. * * Donations still inside the Minimum Data Retention Period are retained; older * donations are fully anonymized. The donor profile is anonymized only when all * of their donations were erased (none retained). * * Handled in a single pass (not paginated like export): the donor-profile decision * needs to know whether *any* donation across the whole set was retained, which * can't be determined from one page in isolation. * * @since 1.2.0 * @param string $email_address The email being erased. * @param int $page Page number (unused — all data handled at once). * @return array{items_removed: bool, items_retained: bool, messages: array, done: bool} */ public function erase( $email_address, $page = 1 ) { unset( $page ); $email = sanitize_email( (string) $email_address ); $items_removed = false; $items_retained = false; $erase_failed = false; $messages = []; $donations = Donations::get_by_donor_email( $email ); foreach ( is_array( $donations ) ? $donations : [] as $donation ) { if ( ! is_array( $donation ) || ! isset( $donation['id'] ) ) { continue; } if ( ! Privacy_Settings::is_donation_erasable( Helper::get_string_value( $donation['created_at'] ?? '' ) ) ) { $items_retained = true; continue; } // Strip the personal-data keys from donation_data, keep the rest // (e.g. refunds/notes are operational records, not donor PII). $donation_data = isset( $donation['donation_data'] ) && is_array( $donation['donation_data'] ) ? $donation['donation_data'] : []; unset( $donation_data['fields'] ); // The receipt PDF is generated from the donor's name/email/address — // erasure must remove the file from disk, not just the DB columns. $receipt_deleted = Receipt_Generator::delete_receipt( Helper::get_string_value( $donation['receipt_pdf_url'] ?? '' ) ); // Besides the direct PII columns, clear pseudonymous identifiers that // re-link the record to the donor at the payment provider (Stripe // customer/subscription ids) and the gateway log (can embed the donor's // email). transaction_id and subscription_status are kept deliberately: // the transaction reference is required for financial reconciliation / // refund handling and identifies the payment, not the person; the // status is a non-identifying enum. $anonymized = [ 'donor_name' => wp_privacy_anonymize_data( 'text', Helper::get_string_value( $donation['donor_name'] ?? '' ) ), 'donor_email' => wp_privacy_anonymize_data( 'email', $email ), 'donor_phone' => '', 'donor_comment' => '', 'ip_address' => wp_privacy_anonymize_data( 'ip', Helper::get_string_value( $donation['ip_address'] ?? '' ) ), 'user_agent' => '', 'referer_url' => '', 'donation_data' => $donation_data, 'customer_id' => '', 'subscription_id' => '', 'parent_subscription_id' => 0, 'log' => '', ]; // Clear the receipt pointer only when the file is actually gone — // otherwise keep it so a retried erasure can still find the file. if ( $receipt_deleted ) { $anonymized['receipt_pdf_url'] = ''; } $updated = Donations::update( absint( Helper::get_string_value( $donation['id'] ) ), $anonymized ); // Donations::update() returns int|false — only report data as removed // when the write actually succeeded; a confirmed erasure for data still // in the database would be a silent compliance failure. if ( false !== $updated && $receipt_deleted ) { $items_removed = true; } else { $erase_failed = true; } } // Anonymize the donor profile only when nothing is being retained for them // (and nothing failed to erase — a failed donation write means PII may still // reference this donor). All-or-nothing by design (Charitable-style model): // while any donation is retained, its row still carries the donor's identity, // so scrubbing only the profile would not reduce what is held; the profile is // erased together with the last retained donation once the window lapses. $donor = Donors::get_by_email( $email ); if ( is_array( $donor ) && ! empty( $donor ) && ! $items_retained && ! $erase_failed ) { $donor_id = absint( Helper::get_string_value( $donor['id'] ?? 0 ) ); if ( $donor_id > 0 ) { Donors::clear_stripe_customer_id_by_email( $email ); // Clear the per-account Stripe customer identifiers added with // multi-account support: the donor_data map (folded into the // anonymization write below) and the per-account user-meta cache. $donor_data = isset( $donor['donor_data'] ) && is_array( $donor['donor_data'] ) ? $donor['donor_data'] : []; unset( $donor_data['stripe_customers'] ); $wp_user = get_user_by( 'email', $email ); if ( $wp_user instanceof \WP_User ) { delete_user_meta( $wp_user->ID, '_stripe_customer_id' ); global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- One-off GDPR erase of prefixed per-account customer-id meta; $wpdb->usermeta is trusted. $meta_keys = $wpdb->get_col( $wpdb->prepare( "SELECT meta_key FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key LIKE %s", $wp_user->ID, $wpdb->esc_like( '_suredonation_stripe_customer_id_' ) . '%' ) ); if ( is_array( $meta_keys ) ) { foreach ( $meta_keys as $meta_key ) { delete_user_meta( $wp_user->ID, (string) $meta_key ); } } } $updated = Donors::update( $donor_id, [ // Keep the email unique per donor to respect the UNIQUE column. 'email' => 'deleted-' . $donor_id . '@site.invalid', 'name' => wp_privacy_anonymize_data( 'text', Helper::get_string_value( $donor['name'] ?? '' ) ), 'phone' => '', 'company' => '', 'address' => '', 'donor_data' => $donor_data, ] ); // Same int|false contract as the donation updates above. if ( false !== $updated ) { $items_removed = true; } else { $erase_failed = true; } } } if ( $items_retained ) { $messages[] = __( 'Some donation data was retained because it falls within the configured data retention period.', 'suredonation' ); } if ( $erase_failed ) { $messages[] = __( 'Some SureDonation data could not be erased due to a database or filesystem error. Please retry or contact the site administrator.', 'suredonation' ); } /** * Fires when a personal-data erasure request has been processed for SureDonation data. * * @since 1.2.0 * @param array{items_removed: bool, items_retained: bool, erase_failed: bool} $outcome Erasure outcome flags. */ do_action( 'suredonation_privacy_data_erased', [ 'items_removed' => $items_removed, 'items_retained' => $items_retained, 'erase_failed' => $erase_failed, ] ); return [ 'items_removed' => $items_removed, 'items_retained' => $items_retained, 'messages' => $messages, 'done' => true, ]; } /** * Convert a label => value map into the WordPress exporter row shape. * * @since 1.2.0 * @param array $map Label => value pairs. * @return array */ private static function name_value_rows( $map ) { $rows = []; foreach ( $map as $label => $value ) { $rows[] = [ 'name' => (string) $label, 'value' => Helper::get_string_value( $value ), ]; } return $rows; } }