Passed
Pull Request — master (#822)
by
unknown
13:41 queued 08:14
created

WPInv_Data_Retention::end_user_session()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 1
eloc 4
c 1
b 1
f 0
nc 1
nop 0
dl 0
loc 7
rs 10
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
            'user_email'      => $user_data ? $user_data->user_email : 'N/A',
422
            'user_registered' => $user_data ? $user_data->user_registered : 'N/A',
423
            'invoice_count'   => count( $invoices ),
424
        );
425
426
        /**
427
         * Filters the additional info before logging.
428
         *
429
         * @since 2.8.22
430
         * @param array  $additional_info The additional information to be logged.
431
         * @param int    $user_id         The ID of the user being processed.
432
         * @param array  $invoices        The invoices being processed.
433
         * @param string $action          The action being performed (anonymize or delete).
434
         */
435
        $additional_info = apply_filters( 'wpinv_anonymization_log_additional_info', $additional_info, $user_id, $invoices, $action );
436
437
        $data = array(
438
            'user_id'         => $user_id,
439
            'action'          => sanitize_text_field( $action ),
440
            'data_type'       => 'invoices',
441
            'timestamp'       => current_time( 'mysql' ),
442
            'additional_info' => wp_json_encode( $additional_info ),
443
        );
444
445
        $format = array(
446
            '%d',  // user_id
447
            '%s',  // action
448
            '%s',  // data_type
449
            '%s',  // timestamp
450
            '%s',  // additional_info
451
        );
452
453
        $result = $wpdb->insert( $table_name, $data, $format );
454
455
        if ( false === $result ) {
456
            wpinv_error_log( sprintf( 'Failed to log anonymization action for user ID: %d. Error: %s', $user_id, $wpdb->last_error ) );
457
            return false;
458
        }
459
460
        /**
461
         * Fires after logging a deletion or anonymization action.
462
         *
463
         * @since 2.8.22
464
         * @param int    $user_id  The ID of the user being processed.
465
         * @param array  $invoices An array of invoice objects being processed.
466
         * @param string $action   The action being performed (anonymize or delete).
467
         * @param array  $data     The data that was inserted into the log.
468
         */
469
        do_action( 'wpinv_after_log_deletion_action', $user_id, $invoices, $action, $data );
470
471
        return true;
472
    }
473
474
    /**
475
     * Handles GDPR personal data erasure request.
476
     *
477
     * @since 2.8.22
478
     * @param array $response The default response.
479
     * @param int   $user_id  The ID of the user being erased.
480
     * @return array The modified response.
481
     */
482
    public function handle_erasure_request( $response, $user_id ) {
483
        if ( $this->has_active_subscriptions( $user_id ) ) {
484
            $response['messages'][]    = esc_html__( 'User has active subscriptions. Data cannot be erased at this time.', 'invoicing' );
485
            $response['items_removed'] = false;
486
        } elseif ( $this->has_paid_invoices( $user_id ) ) {
487
            $retention_method = wpinv_get_option( 'data_retention_method', 'anonymize' );
488
            if ( 'anonymize' === $retention_method ) {
489
                $this->anonymize_user_data( $user_id );
490
                $response['messages'][]     = esc_html__( 'User data has been anonymized due to existing paid invoices.', 'invoicing' );
491
                $response['items_removed']  = false;
492
                $response['items_retained'] = true;
493
            } else {
494
                $this->delete_user_data( $user_id );
495
                $response['messages'][]     = esc_html__( 'User data has been deleted.', 'invoicing' );
496
                $response['items_removed']  = true;
497
                $response['items_retained'] = false;
498
            }
499
        }
500
501
        return $response;
502
    }
503
504
    /**
505
     * Hashes email for anonymization.
506
     *
507
     * @since 2.8.22
508
     * @param string $email The email to hash.
509
     * @return string The hashed email.
510
     */
511
    private function hash_email( $email ) {
512
        $site_url = get_site_url();
513
        $domain   = wp_parse_url( $site_url, PHP_URL_HOST );
514
515
        if ( empty( $domain ) ) {
516
            return $email;
517
        }
518
519
        $clean_email     = sanitize_email( strtolower( trim( $email ) ) );
520
        $hash            = wp_hash( $clean_email );
521
        $hash            = substr( $hash, 0, 20 );
522
        $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

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