WPInv_Data_Retention::prevent_user_deletion()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 18
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 10
c 1
b 0
f 0
nc 2
nop 2
dl 0
loc 18
rs 9.9332
1
<?php
2
/**
3
 * Data retention class.
4
 *
5
 * @package Invoicing
6
 * @since   2.8.22
7
 */
8
9
defined( 'ABSPATH' ) || exit;
10
11
/**
12
 * WPInv_Data_Retention Class.
13
 *
14
 * Handles user data anonymization and deletion.
15
 *
16
 * @since 2.8.22
17
 */
18
class WPInv_Data_Retention {
19
20
    /**
21
     * Error message.
22
     *
23
     * @var string
24
     */
25
    private $error_message;
26
27
    /**
28
     * Flag to control whether user deletion should be handled.
29
     *
30
     * @var bool
31
     */
32
    private $handle_user_deletion = true;
33
34
    /**
35
     * Class constructor.
36
     */
37
    public function __construct() {
38
        add_filter( 'wpinv_settings_misc', array( $this, 'add_data_retention_settings' ) );
39
40
        add_action( 'wpmu_delete_user', array( $this, 'maybe_handle_user_deletion' ), 1 );
41
        add_action( 'delete_user', array( $this, 'maybe_handle_user_deletion' ), 1 );
42
        add_filter( 'wp_privacy_personal_data_erasure_request', array( $this, 'handle_erasure_request' ), 10, 2 );
43
44
        add_action( 'getpaid_daily_maintenance', array( $this, 'perform_data_retention_cleanup' ) );
45
    }
46
47
    /**
48
     * Adds data retention settings to the misc settings page.
49
     *
50
     * @param array $misc_settings Existing misc settings.
51
     * @return array Updated misc settings.
52
     */
53
    public function add_data_retention_settings( $misc_settings ) {
54
        $misc_settings['data_retention'] = array(
55
            'id'   => 'data_retention',
56
            'name' => '<h3>' . __( 'Data Retention', 'invoicing' ) . '</h3>',
57
            'type' => 'header',
58
        );
59
60
        $misc_settings['data_retention_method'] = array(
61
            'id'      => 'data_retention_method',
62
            'name'    => __( 'Data Handling', 'invoicing' ),
63
            'desc'    => __( 'Choose how to handle user data when deletion is required.', 'invoicing' ),
64
            'type'    => 'select',
65
            'options' => array(
66
                'anonymize' => __( 'Anonymize data', 'invoicing' ),
67
                'delete'    => __( 'Delete data without anonymization', 'invoicing' ),
68
            ),
69
            'std'     => 'anonymize',
70
            'tooltip' => __( 'Anonymization replaces personal data with non-identifiable information. Direct deletion removes all data permanently.', 'invoicing' ),
71
        );
72
73
        $misc_settings['data_retention_period'] = array(
74
            'id'      => 'data_retention_period',
75
            'name'    => __( 'Retention Period', 'invoicing' ),
76
            'desc'    => __( 'Specify how long to retain customer data after processing.', 'invoicing' ),
77
            'type'    => 'select',
78
            'options' => array(
79
                'never' => __( 'Never delete (retain indefinitely)', 'invoicing' ),
80
                '30'    => __( '30 days', 'invoicing' ),
81
                '90'    => __( '90 days', 'invoicing' ),
82
                '180'   => __( '6 months', 'invoicing' ),
83
                '365'   => __( '1 year', 'invoicing' ),
84
                '730'   => __( '2 years', 'invoicing' ),
85
                '1825'  => __( '5 years', 'invoicing' ),
86
                '3650'  => __( '10 years', 'invoicing' ),
87
            ),
88
            'std'     => '3650',
89
            'tooltip' => __( 'Choose how long to keep processed customer data before final action. This helps balance data minimization with business needs.', 'invoicing' ),
90
        );
91
92
        return $misc_settings;
93
    }
94
95
    /**
96
     * Conditionally handles user deletion based on the flag.
97
     *
98
     * @param int $user_id The ID of the user being deleted.
99
     */
100
    public function maybe_handle_user_deletion( $user_id ) {
101
        if ( ! $this->handle_user_deletion ) {
102
            return;
103
        }
104
105
        if ( current_user_can( 'manage_options' ) ) {
106
            $this->handle_admin_user_deletion( $user_id );
107
        } else {
108
            $this->handle_self_account_deletion( $user_id );
109
        }
110
    }
111
112
    /**
113
     * Handles admin-initiated user deletion process.
114
     *
115
     * @since 2.8.22
116
     * @param int $user_id The ID of the user being deleted.
117
     */
118
    public function handle_admin_user_deletion( $user_id ) {
119
        if ( $this->has_active_subscriptions( $user_id ) ) {
120
            $this->prevent_user_deletion( $user_id, 'active_subscriptions' );
121
            return;
122
        }
123
124
        if ( $this->has_paid_invoices( $user_id ) ) {
125
            $retention_method = wpinv_get_option( 'data_retention_method', 'anonymize' );
126
            if ( 'anonymize' === $retention_method ) {
127
                $this->anonymize_user_data( $user_id );
128
                $this->prevent_user_deletion( $user_id, 'paid_invoices' );
129
            } else {
130
                $this->delete_user_data( $user_id );
131
            }
132
        }
133
    }
134
135
    /**
136
     * Handles user account self-deletion.
137
     *
138
     * @since 2.8.22
139
     * @param int $user_id The ID of the user being deleted.
140
     */
141
    public function handle_self_account_deletion( $user_id ) {
142
        $this->cancel_active_subscriptions( $user_id );
143
144
        if ( $this->has_paid_invoices( $user_id ) ) {
145
            $retention_method = wpinv_get_option( 'data_retention_method', 'anonymize' );
146
147
            if ( 'anonymize' === $retention_method ) {
148
                $user = get_userdata( $user_id );
149
150
                $this->anonymize_user_data( $user_id );
151
152
                $message = apply_filters( 'uwp_get_account_deletion_message', '', $user );
153
                do_action( 'uwp_send_account_deletion_emails', $user, $message );
154
155
                $this->end_user_session();
156
            }
157
        }
158
    }
159
160
    /**
161
     * Checks if user has active subscriptions.
162
     *
163
     * @since 2.8.22
164
     * @param int $user_id The ID of the user being checked.
165
     * @return bool True if user has active subscriptions, false otherwise.
166
     */
167
    private function has_active_subscriptions( $user_id ) {
168
        $subscriptions = getpaid_get_subscriptions(
169
            array(
170
                'customer_in' => array( (int) $user_id ),
171
                'status'      => 'active',
172
            )
173
        );
174
175
        return ! empty( $subscriptions );
176
    }
177
178
    /**
179
     * Cancels all active subscriptions for a user.
180
     *
181
     * @since 2.8.22
182
     * @param int $user_id The ID of the user.
183
     */
184
    private function cancel_active_subscriptions( $user_id ) {
185
        $subscriptions = getpaid_get_subscriptions(
186
            array(
187
                'customer_in' => array( (int) $user_id ),
188
                'status'      => 'active',
189
            )
190
        );
191
192
        foreach ( $subscriptions as $subscription ) {
193
            $subscription->cancel();
194
        }
195
    }
196
197
    /**
198
     * Checks if user has paid invoices.
199
     *
200
     * @since 2.8.22
201
     * @param int $user_id The ID of the user being checked.
202
     * @return bool True if user has paid invoices, false otherwise.
203
     */
204
    private function has_paid_invoices( $user_id ) {
205
        $invoices = wpinv_get_invoices(
206
            array(
207
                'user'   => (int) $user_id,
208
                'status' => 'publish',
209
            )
210
        );
211
212
        return ! empty( $invoices->total );
213
    }
214
215
    /**
216
     * Prevents user deletion by setting an error message and stopping execution.
217
     *
218
     * @since 2.8.22
219
     * @param int    $user_id The ID of the user being deleted.
220
     * @param string $reason  The reason for preventing deletion.
221
     */
222
    private function prevent_user_deletion( $user_id, $reason ) {
223
        $user = get_userdata( $user_id );
224
225
        if ( 'active_subscriptions' === $reason ) {
226
            $this->error_message = sprintf(
227
                /* translators: %s: user login */
228
                esc_html__( 'User deletion for %s has been halted. All active subscriptions should be cancelled first.', 'invoicing' ),
229
                $user->user_login
230
            );
231
        } else {
232
            $this->error_message = sprintf(
233
                /* translators: %s: user login */
234
                esc_html__( 'User deletion for %s has been halted due to paid invoices. Data will be anonymized instead.', 'invoicing' ),
235
                $user->user_login
236
            );
237
        }
238
239
        wp_die( $this->error_message, esc_html__( 'User Deletion Halted', 'invoicing' ), array( 'response' => 403 ) );
240
    }
241
242
    /**
243
     * Anonymizes user data.
244
     *
245
     * @since 2.8.22
246
     * @param int $user_id The ID of the user to anonymize.
247
     * @return bool True on success, false on failure.
248
     */
249
    private function anonymize_user_data( $user_id ) {
250
        global $wpdb;
251
252
        $user = get_userdata( $user_id );
253
        if ( ! $user ) {
254
            return false;
255
        }
256
257
        $table_name    = $wpdb->prefix . 'getpaid_customers';
258
        $deletion_date = gmdate( 'Y-m-d', strtotime( '+10 years' ) );
259
        $hashed_email  = $this->hash_email( $user->user_email );
260
261
        $updated = $wpdb->update(
262
            $table_name,
263
            array(
264
                'is_anonymized' => 1,
265
                'deletion_date' => $deletion_date,
266
                'email'         => $hashed_email,
267
                'email_cc'      => $hashed_email,
268
                'phone'         => '',
269
            ),
270
            array( 'user_id' => (int) $user->ID )
271
        );
272
273
        if ( false === $updated ) {
274
            return false;
275
        }
276
277
        wp_update_user(
278
            array(
279
                'ID'         => (int) $user->ID,
280
                'user_email' => $hashed_email,
281
            )
282
        );
283
284
        /**
285
         * Fires when anonymizing user meta fields.
286
         *
287
         * @since 2.8.22
288
         * @param int $user_id The ID of the user being anonymized.
289
         */
290
        do_action( 'wpinv_anonymize_user_meta_data', $user->ID );
291
292
        $user_meta_data = array(
293
            'nickname',
294
			'description',
295
			'rich_editing',
296
			'syntax_highlighting',
297
			'comment_shortcuts',
298
            'admin_color',
299
			'use_ssl',
300
			'show_admin_bar_front',
301
			'locale',
302
			'wp_capabilities',
303
            'wp_user_level',
304
			'dismissed_wp_pointers',
305
			'show_welcome_panel',
306
        );
307
308
        /**
309
         * Filters the user meta fields to be anonymized.
310
         *
311
         * @since 2.8.22
312
         * @param array $user_meta_data The meta fields to be anonymized.
313
         * @param int   $user_id          The ID of the user being anonymized.
314
         */
315
        $user_meta_data = apply_filters( 'wpinv_user_meta_data_to_anonymize', $user_meta_data, $user->ID );
316
317
        foreach ( $user_meta_data as $meta_key ) {
318
            delete_user_meta( $user->ID, $meta_key );
319
        }
320
321
        return $this->ensure_invoice_anonymization( $user->ID, 'anonymize' );
322
    }
323
324
    /**
325
     * Deletes user data without anonymization.
326
     *
327
     * @param int $user_id The ID of the user to delete.
328
     * @return bool True on success, false on failure.
329
     */
330
    private function delete_user_data( $user_id ) {
331
        // Delete associated invoices.
332
        $this->ensure_invoice_anonymization( $user_id, 'delete' );
333
334
        // Delete the user.
335
        if ( is_multisite() ) {
336
            wpmu_delete_user( $user_id );
337
        } else {
338
            wp_delete_user( $user_id );
339
        }
340
341
        /**
342
         * Fires after deleting user data without anonymization.
343
         *
344
         * @since 2.8.22
345
         * @param int $user_id The ID of the user being deleted.
346
         */
347
        do_action( 'wpinv_delete_user_data', $user_id );
348
349
        return true;
350
    }
351
352
    /**
353
     * Ensures invoice data remains anonymized.
354
     *
355
     * @since 2.8.22
356
     * @param int    $user_id The ID of the user whose invoices should be checked.
357
     * @param string $action  The action to perform (anonymize or delete).
358
     * @return bool True on success, false on failure.
359
     */
360
    public function ensure_invoice_anonymization( $user_id, $action = 'anonymize' ) {
361
        $invoices = wpinv_get_invoices( array( 'user' => $user_id ) );
362
363
        /**
364
         * Filters the invoice meta fields to be anonymized.
365
         *
366
         * @since 2.8.22
367
         * @param array $inv_meta_data The meta fields to be anonymized.
368
         * @param int   $user_id         The ID of the user being processed.
369
         */
370
        $inv_meta_data = apply_filters( 'wpinv_invoice_meta_data_to_anonymize', array(), $user_id );
371
372
        foreach ( $invoices->invoices as $invoice ) {
373
            foreach ( $inv_meta_data as $meta_key ) {
374
                delete_post_meta( $invoice->get_id(), $meta_key );
375
            }
376
377
            if ( 'anonymize' === $action ) {
378
                $hashed_inv_email    = $this->hash_email( $invoice->get_email() );
379
                $hashed_inv_email_cc = $this->hash_email( $invoice->get_email_cc() );
380
381
                $invoice->set_email( $hashed_inv_email );
382
                $invoice->set_email_cc( $hashed_inv_email_cc );
383
                $invoice->set_phone( '' );
384
                $invoice->set_ip( $this->anonymize_data( $invoice->get_ip() ) );
385
                $invoice->set_is_anonymized( 1 );
386
387
                /**
388
                 * Fires when anonymizing additional invoice data.
389
                 *
390
                 * @since 2.8.22
391
                 * @param WPInv_Invoice $invoice The invoice being anonymized.
392
                 * @param string        $action  The action being performed (anonymize or delete).
393
                 */
394
                do_action( 'wpinv_anonymize_invoice_data', $invoice, $action );
395
396
                $invoice->save();
397
            } else {
398
                $invoice->delete();
399
            }
400
        }
401
402
        return $this->log_deletion_action( $user_id, $invoices->invoices, $action );
403
    }
404
405
    /**
406
     * Logs the deletion or anonymization action for a user and their invoices.
407
     *
408
     * @since 2.8.22
409
     * @param int    $user_id  The ID of the user being processed.
410
     * @param array  $invoices An array of invoice objects being processed.
411
     * @param string $action   The action being performed (anonymize or delete).
412
     * @return bool True on success, false on failure.
413
     */
414
    private function log_deletion_action( $user_id, $invoices, $action ) {
415
        global $wpdb;
416
417
        $table_name = $wpdb->prefix . 'getpaid_anonymization_logs';
418
        $user_data  = get_userdata( $user_id );
419
420
        $additional_info = array(
421
            'Username'      => $user_data ? $user_data->user_login : 'N/A',
422
            'User Roles'    => $user_data ? implode(', ', $user_data->roles) : 'N/A',
423
            'Email'         => $user_data ? $user_data->user_email : 'N/A',
424
            'First Name'    => $user_data ? $user_data->first_name : 'N/A',
425
            'Last Name'     => $user_data ? $user_data->last_name : 'N/A',
426
            'Registered'    => $user_data ? $user_data->user_registered : 'N/A',
427
            'invoice_count' => count( $invoices ),
428
        );
429
430
431
        /**
432
         * Filters the additional info before logging.
433
         *
434
         * @since 2.8.22
435
         * @param array  $additional_info The additional information to be logged.
436
         * @param int    $user_id         The ID of the user being processed.
437
         * @param array  $invoices        The invoices being processed.
438
         * @param string $action          The action being performed (anonymize or delete).
439
         */
440
        $additional_info = apply_filters( 'wpinv_anonymization_log_additional_info', $additional_info, $user_id, $invoices, $action );
441
442
        $data = array(
443
            'user_id'         => $user_id,
444
            'action'          => sanitize_text_field( $action ),
445
            'data_type'       => 'User Invoices',
446
            'timestamp'       => current_time( 'mysql' ),
447
            'additional_info' => wp_json_encode( $additional_info ),
448
        );
449
450
        $format = array(
451
            '%d',  // user_id
452
            '%s',  // action
453
            '%s',  // data_type
454
            '%s',  // timestamp
455
            '%s',  // additional_info
456
        );
457
458
        if ( ! empty( $user_id ) && ! empty( $action ) ) {
459
            $result = $wpdb->update(
460
                $table_name,
461
                $data,
462
                array(
463
                    'user_id' => (int) $user_id,
464
                    'action'  => sanitize_text_field( $action ),
465
                ),
466
                $format,
467
                array( '%d', '%s' )
468
            );
469
470
            if ( false === $result ) {
471
                // If update fails, try to insert.
472
                $result = $wpdb->insert( $table_name, $data, $format );
473
            }
474
475
            if ( false === $result ) {
476
                wpinv_error_log( sprintf( 'Failed to log anonymization action for user ID: %d. Error: %s', $user_id, $wpdb->last_error ) );
477
                return false;
478
            }
479
        }
480
481
        /**
482
         * Fires after logging a deletion or anonymization action.
483
         *
484
         * @since 2.8.22
485
         * @param int    $user_id  The ID of the user being processed.
486
         * @param array  $invoices An array of invoice objects being processed.
487
         * @param string $action   The action being performed (anonymize or delete).
488
         * @param array  $data     The data that was inserted into the log.
489
         */
490
        do_action( 'wpinv_after_log_deletion_action', $user_id, $invoices, $action, $data );
491
492
        return true;
493
    }
494
495
    /**
496
     * Handles GDPR personal data erasure request.
497
     *
498
     * @since 2.8.22
499
     * @param array $response The default response.
500
     * @param int   $user_id  The ID of the user being erased.
501
     * @return array The modified response.
502
     */
503
    public function handle_erasure_request( $response, $user_id ) {
504
        if ( $this->has_active_subscriptions( $user_id ) ) {
505
            $response['messages'][]    = esc_html__( 'User has active subscriptions. Data cannot be erased at this time.', 'invoicing' );
506
            $response['items_removed'] = false;
507
        } elseif ( $this->has_paid_invoices( $user_id ) ) {
508
            $retention_method = wpinv_get_option( 'data_retention_method', 'anonymize' );
509
            if ( 'anonymize' === $retention_method ) {
510
                $this->anonymize_user_data( $user_id );
511
                $response['messages'][]     = esc_html__( 'User data has been anonymized due to existing paid invoices.', 'invoicing' );
512
                $response['items_removed']  = false;
513
                $response['items_retained'] = true;
514
            } else {
515
                $this->delete_user_data( $user_id );
516
                $response['messages'][]     = esc_html__( 'User data has been deleted.', 'invoicing' );
517
                $response['items_removed']  = true;
518
                $response['items_retained'] = false;
519
            }
520
        }
521
522
        return $response;
523
    }
524
525
    /**
526
     * Hashes email for anonymization.
527
     *
528
     * @since 2.8.22
529
     * @param string $email The email to hash.
530
     * @return string The hashed email.
531
     */
532
    private function hash_email( $email ) {
533
        $site_url = get_site_url();
534
        $domain   = wp_parse_url( $site_url, PHP_URL_HOST );
535
536
        if ( empty( $domain ) ) {
537
            return $email;
538
        }
539
540
        $clean_email     = sanitize_email( strtolower( trim( $email ) ) );
541
        $hash            = wp_hash( $clean_email );
542
        $hash            = substr( $hash, 0, 20 );
543
        $anonymized_email = sprintf( '%s@%s', $hash, $domain );
0 ignored issues
show
Bug introduced by
It seems like $domain can also be of type array; however, parameter $values of sprintf() does only seem to accept double|integer|string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

543
        $anonymized_email = sprintf( '%s@%s', $hash, /** @scrutinizer ignore-type */ $domain );
Loading history...
544
545
        /**
546
         * Filters the anonymized email before returning.
547
         *
548
         * @since 2.8.22
549
         * @param string $anonymized_email The anonymized email address.
550
         * @param string $email            The original email address.
551
         */
552
        return apply_filters( 'wpinv_anonymized_email', $anonymized_email, $email );
553
    }
554
555
    /**
556
     * Anonymizes a given piece of data.
557
     *
558
     * @since 2.8.22
559
     * @param string $data The data to anonymize.
560
     * @return string The anonymized data.
561
     */
562
    private function anonymize_data( $data ) {
563
        if ( empty( $data ) ) {
564
            return '';
565
        }
566
567
        return wp_privacy_anonymize_data( 'text', $data );
568
    }
569
570
    /**
571
     * Performs data retention cleanup.
572
     *
573
     * This method is responsible for cleaning up anonymized user data
574
     * that has exceeded the retention period.
575
     *
576
     * @since 2.8.22
577
     */
578
    public function perform_data_retention_cleanup() {
579
        global $wpdb;
580
581
        $retention_period = wpinv_get_option( 'data_retention_period', '3650' );
582
583
        // If retention period is set to 'never', exit the function.
584
        if ( 'never' === $retention_period ) {
585
            return;
586
        }
587
588
        $customers_table = $wpdb->prefix . 'getpaid_customers';
589
590
        // Calculate the cutoff date for data retention.
591
        $cutoff_date = gmdate( 'Y-m-d', strtotime( "-$retention_period days" ) );
592
593
        $expired_records = $wpdb->get_results(
594
            $wpdb->prepare(
595
                "SELECT * FROM $customers_table WHERE deletion_date < %s AND is_anonymized = 1",
596
                $cutoff_date
597
            )
598
        );
599
600
        /**
601
         * Fires before the data retention cleanup process begins.
602
         *
603
         * @since 2.8.22
604
         * @param array $expired_records Array of customer records to be processed.
605
         */
606
        do_action( 'getpaid_data_retention_before_cleanup', $expired_records );
607
608
        if ( ! empty( $expired_records ) ) {
609
            // Disable our custom user deletion handling.
610
            $this->handle_user_deletion = false;
611
612
            foreach ( $expired_records as $record ) {
613
                // Delete associated invoices.
614
                $this->ensure_invoice_anonymization( (int) $record->user_id, 'delete' );
615
616
                // Delete the user.
617
                wp_delete_user( (int) $record->user_id );
618
619
                /**
620
                 * Fires after processing each expired record during cleanup.
621
                 *
622
                 * @since 2.8.22
623
                 * @param object $record The customer record being processed.
624
                 */
625
                do_action( 'getpaid_data_retention_process_record', $record );
626
            }
627
628
            // Re-enable our custom user deletion handling.
629
            $this->handle_user_deletion = true;
630
631
            /**
632
             * Fires after the data retention cleanup process is complete.
633
             *
634
             * @since 2.8.22
635
             * @param array $expired_records Array of customer records that were processed.
636
             */
637
            do_action( 'getpaid_data_retention_after_cleanup', $expired_records );
638
        }
639
640
        /**
641
         * Fires after the data retention cleanup attempt, regardless of whether records were processed.
642
         *
643
         * @since 2.8.22
644
         * @param int $retention_period The current retention period in years.
645
         * @param string $cutoff_date The cutoff date used for identifying expired records.
646
         */
647
        do_action( 'getpaid_data_retention_cleanup_complete', $retention_period, $cutoff_date );
648
    }
649
650
    /**
651
     * Ends the user's current session.
652
     *
653
     * @since 2.8.22
654
     */
655
    private function end_user_session() {
656
        wp_logout();
657
658
        // Redirect after deletion.
659
        $redirect_page = home_url();
660
        wp_safe_redirect( $redirect_page );
661
        exit();
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
662
    }
663
}