0, 'sms_days' => 0, 'email_days' => 0, 'financial_hold' => true, 'held_customers' => array() ); if ( ! is_array( $raw ) ) { throw new \RuntimeException( 'privacy_policy' ); } foreach ( array( 'customer_days', 'sms_days', 'email_days' ) as$key ) { $value = $raw[ $key ] ?? 0; if ( ! is_int( $value ) || $value < 0 || $value > 36500 || ( $value > 0 && $value < 30 ) ) { throw new \RuntimeException( 'privacy_policy' ); } $out[ $key ] = $value; } if ( ! is_bool( $raw['financial_hold'] ?? true ) || ! is_array( $raw['held_customers'] ?? array() ) ) { throw new \RuntimeException( 'privacy_policy' ); } $out['financial_hold'] = $raw['financial_hold'] ?? true; foreach ( $raw['held_customers'] ?? array() as$id ) { if ( ! is_int( $id ) || $id < 1 ) { throw new \RuntimeException( 'privacy_policy' ); } $out['held_customers'][] = $id; } if ( count( $out['held_customers'] ) > 1000 ) { throw new \RuntimeException( 'privacy_policy' ); } return $out; } public static function permitted(): bool { return current_user_can( 'abientot_manage_settings' ) && current_user_can( 'abientot_manage_customers' ); } public function save( array $input ): void { if ( ! self::permitted() ) { throw new \RuntimeException( 'privacy_permission' ); } $out = array(); foreach ( array( 'customer_days', 'sms_days', 'email_days' ) as$key ) { if ( ! is_string( $input[ $key ] ?? null ) || ! preg_match( '/^[0-9]{1,5}$/D', $input[ $key ] ) ) { throw new \DomainException( 'privacy_policy' ); } $n = (int) $input[ $key ]; if ( $n > 36500 || ( $n > 0 && $n < 30 ) ) { throw new \DomainException( 'privacy_policy' ); } $out[ $key ] = $n; } $out['financial_hold'] = ( $input['financial_hold'] ?? '' ) === '1'; $list = $input['held_customers'] ?? ''; if ( ! is_string( $list ) ) { throw new \DomainException( 'privacy_policy' ); } $out['held_customers'] = array(); foreach ( preg_split( '/[\s,]+/', trim( $list ), -1, PREG_SPLIT_NO_EMPTY ) as$id ) { if ( ! preg_match( '/^[1-9][0-9]{0,9}$/D', $id ) ) { throw new \DomainException( 'privacy_policy' ); } $out['held_customers'][] = (int) $id; } $out['held_customers'] = array_values( array_unique( $out['held_customers'] ) ); if ( count( $out['held_customers'] ) > 1000 ) { throw new \DomainException( 'privacy_policy' ); } if ( BookingLock::is_held() || ! BookingLock::acquire() ) { throw new \RuntimeException( 'privacy_lock' ); } try { \Abientot\Support\TransactionGuard::prepare_write(); update_option( self::OPTION, $out, false ); if ( get_option( self::OPTION ) !== $out ) { throw new \RuntimeException( 'privacy_write' ); } } finally { BookingLock::release(); } } private function checked( $rows ): array { global $wpdb; if ( $wpdb->last_error || ! is_array( $rows ) ) { throw new \RuntimeException( 'privacy_read' ); } return $rows; } private function cutoff( int $days ): string { return gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ); } private function waitlist_present(): bool { global $wpdb; $p = $wpdb->prefix . 'abientot_'; $defs = array( 'waitlist_entries' => array( 'id', 'customer_id', 'service_id', 'location_id', 'session_id', 'date_from', 'date_to', 'state', 'created_at', 'updated_at' ), 'waitlist_offers' => array( 'id', 'entry_id', 'submission_uuid', 'command_hash', 'bearer_hash', 'state', 'expires_at', 'service_id', 'staff_id', 'location_id', 'session_id', 'start_utc', 'end_utc', 'timezone', 'duration_minutes', 'price_cents', 'currency', 'appointment_id', 'created_at', 'updated_at' ) ); $found = 0; foreach ( $defs as$name => $expected ) { $table = $p . $name; $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ); if ( $wpdb->last_error ) { throw new \RuntimeException( 'privacy_read' ); } if ( ! $exists ) { continue; } $found++; $columns = array_column( $this->checked( $wpdb->get_results( $wpdb->prepare( 'SHOW COLUMNS FROM %i', $table ), ARRAY_A ) ), 'Field' ); if ( array_diff( $columns, $expected ) || array_diff( $expected, $columns ) ) { throw new \RuntimeException( 'privacy_schema' ); } $engine = $wpdb->get_var( $wpdb->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $table ) ); if ( $wpdb->last_error || strtoupper( (string) $engine ) !== 'INNODB' ) { throw new \RuntimeException( 'privacy_schema' ); } } $version = get_option( 'abientot_waitlist_schema_version', '' ); if ( $found === 0 && $version === '' ) { return false; } if ( $found !== 2 || $version !== '1' ) { throw new \RuntimeException( 'privacy_schema' ); } return true; } /** Customers (in id order) that an optional table reports as still active; every query is a fixed statement over one page of ids. */ private function protected_customers( array $ids, array $policy, array $has ): array { global $wpdb; $cut = gmdate( 'Y-m-d H:i:s' ); $hold = $policy['financial_hold'] ? 1 : 0; $blocked = array(); if ( $has['credit'] ) { $found = $this->checked( $wpdb->get_results( $wpdb->prepare( "SELECT g.customer_id AS cid FROM {$wpdb->prefix}abientot_credit_grants g WHERE g.customer_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND ((g.state='active' AND (g.expires_at IS NULL OR g.expires_at>=%s)) OR ( %d = 1 AND g.acquisition_cents>0 ))", array_merge( $ids, array( $cut, $hold ) ) ), ARRAY_A ) ); foreach ( $found as$row ) { $blocked[ (int) $row['cid'] ] = true; } } if ( $has['series'] ) { $found = $this->checked( $wpdb->get_results( $wpdb->prepare( "SELECT a.customer_id AS cid FROM {$wpdb->prefix}abientot_recurrence_series r INNER JOIN {$wpdb->prefix}abientot_appointments a ON a.id=r.template_id WHERE a.customer_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND r.state NOT IN ('erased','cancelled','completed')", $ids ), ARRAY_A ) ); foreach ( $found as$row ) { $blocked[ (int) $row['cid'] ] = true; } } if ( $has['public_waitlist'] ) { $found = $this->checked( $wpdb->get_results( $wpdb->prepare( "SELECT w.customer_id AS cid FROM {$wpdb->prefix}abientot_waitlist_preferences w WHERE w.customer_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND w.state NOT IN ('erased','expired','cancelled','fulfilled')", $ids ), ARRAY_A ) ); foreach ( $found as$row ) { $blocked[ (int) $row['cid'] ] = true; } } if ( $has['waitlist'] ) { $found = $this->checked( $wpdb->get_results( $wpdb->prepare( "SELECT e.customer_id AS cid FROM {$wpdb->prefix}abientot_waitlist_entries e WHERE e.customer_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND e.state NOT IN ('erased','expired','cancelled','fulfilled')", $ids ), ARRAY_A ) ); foreach ( $found as$row ) { $blocked[ (int) $row['cid'] ] = true; } $found = $this->checked( $wpdb->get_results( $wpdb->prepare( "SELECT e.customer_id AS cid FROM {$wpdb->prefix}abientot_waitlist_offers o INNER JOIN {$wpdb->prefix}abientot_waitlist_entries e ON e.id=o.entry_id WHERE e.customer_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND o.state NOT IN ('erased','expired','revoked','consumed','cancelled')", $ids ), ARRAY_A ) ); foreach ( $found as$row ) { $blocked[ (int) $row['cid'] ] = true; } } if ( $has['plans'] ) { $found = $this->checked( $wpdb->get_results( $wpdb->prepare( "SELECT a.customer_id AS cid FROM {$wpdb->prefix}abientot_payment_plans plan INNER JOIN {$wpdb->prefix}abientot_appointments a ON a.id=plan.appointment_id WHERE a.customer_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND plan.gross_cents>0 AND plan.privacy_erased=0 AND plan.collection_frozen=0 AND (SELECT COALESCE(SUM(r.amount_cents),0) FROM {$wpdb->prefix}abientot_payment_records r WHERE r.appointment_id=a.id AND r.status='paid')checked( $wpdb->get_results( $wpdb->prepare( "SELECT a.customer_id AS cid FROM {$wpdb->prefix}abientot_sms_jobs j INNER JOIN {$wpdb->prefix}abientot_appointments a ON a.id=j.appointment_id WHERE a.customer_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND ( %d = 1 OR (j.state NOT IN ('accepted','rejected','cancelled','invalid') OR j.result_at IS NULL OR j.result_at>=%s) )", array_merge( $ids, array( $policy['sms_days'] === 0 ? 1 : 0, $this->cutoff( $policy['sms_days'] ) ) ) ), ARRAY_A ) ); foreach ( $found as$row ) { $blocked[ (int) $row['cid'] ] = true; } } if ( $has['sms_state'] ) { $found = $this->checked( $wpdb->get_results( $wpdb->prepare( "SELECT a.customer_id AS cid FROM {$wpdb->prefix}abientot_sms_state j INNER JOIN {$wpdb->prefix}abientot_appointments a ON a.id=j.appointment_id WHERE a.customer_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND ( %d = 1 OR a.end_utc>=%s )", array_merge( $ids, array( $policy['sms_days'] === 0 ? 1 : 0, $this->cutoff( $policy['sms_days'] ) ) ) ), ARRAY_A ) ); foreach ( $found as$row ) { $blocked[ (int) $row['cid'] ] = true; } } if ( $has['email'] ) { $found = $this->checked( $wpdb->get_results( $wpdb->prepare( "SELECT a.customer_id AS cid FROM {$wpdb->prefix}abientot_notification_attempts n INNER JOIN {$wpdb->prefix}abientot_appointments a ON a.id=n.appointment_id WHERE a.customer_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND ( %d = 1 OR n.occurred_at>=%s )", array_merge( $ids, array( $policy['email_days'] === 0 ? 1 : 0, $this->cutoff( $policy['email_days'] ) ) ) ), ARRAY_A ) ); foreach ( $found as$row ) { $blocked[ (int) $row['cid'] ] = true; } } return $blocked; } private function candidates( array $policy ): array { global $wpdb; $out = array( 'customers' => array(), 'sms' => array(), 'sms_state' => array(), 'email' => array() ); if ( $policy['customer_days'] > 0 ) { $cut = $this->cutoff( $policy['customer_days'] ); $held = $policy['held_customers'] ? array_map( 'intval', $policy['held_customers'] ) : array( 0 ); $hold = $policy['financial_hold'] ? 1 : 0; // Routine cleanup must not revoke paid/active entitlements or unresolved work. $has = array( 'credit' => ( new \Abientot\Repositories\CreditRepository() )->present(), 'series' => ( new \Abientot\Repositories\CustomerSeriesRepository() )->present(), 'public_waitlist' => ( new \Abientot\Repositories\PublicWaitlistRepository() )->present(), 'waitlist' => $this->waitlist_present(), 'plans' => ( new \Abientot\Repositories\PaymentPlanRepository() )->present(), 'sms_jobs' => false, 'sms_state' => false, 'email' => false ); ( new \Abientot\Repositories\RetainedSmsPrivacyRepository() )->export_rows( array(), 1 ); ( new \Abientot\Repositories\RetainedSmsPrivacyRepository() )->export_rows( array(), 1, 'state' ); foreach ( array( 'jobs', 'state' ) as$kind ) { $t = $wpdb->prefix . 'abientot_sms_' . $kind; $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $t ) ) ); if ( $wpdb->last_error ) { throw new \RuntimeException( 'privacy_read' ); } if ( $exists === $t ) { $has[ 'sms_' . $kind ] = true; } } $has['email'] = ( new \Abientot\Repositories\NotificationAttemptsRepository() )->present(); $last = 0; while ( count( $out['customers'] ) < 100 ) { $page = $this->checked( $wpdb->get_results( $wpdb->prepare( "SELECT c.id,c.updated_at FROM {$wpdb->prefix}abientot_customers c WHERE c.id>%d AND c.updated_at<%s AND (COALESCE(c.email,'')<>'' OR COALESCE(c.phone,'')<>'' OR COALESCE(c.notes,'')<>'') AND NOT EXISTS(SELECT 1 FROM {$wpdb->prefix}abientot_appointments a WHERE a.customer_id=c.id AND (a.end_utc>=%s OR a.updated_at>=%s)) AND c.id NOT IN (" . implode( ',', array_fill( 0, count( $held ), '%d' ) ) . ") AND ( %d = 0 OR NOT EXISTS(SELECT 1 FROM {$wpdb->prefix}abientot_payment_records r INNER JOIN {$wpdb->prefix}abientot_appointments a ON a.id=r.appointment_id WHERE a.customer_id=c.id AND (r.amount_cents>0 OR COALESCE(r.transaction_reference,'')<>''))) ORDER BY c.id LIMIT 500", array_merge( array( $last, $cut, $cut, $cut ), $held, array( $hold ) ) ), ARRAY_A ) ); if ( ! $page ) { break; } $blocked = $this->protected_customers( array_map( 'intval', array_column( $page, 'id' ) ), $policy, $has ); foreach ( $page as$row ) { if ( count( $out['customers'] ) >= 100 ) { break; } if ( ! isset( $blocked[ (int) $row['id'] ] ) ) { $out['customers'][] = $row; } } if ( count( $page ) < 500 ) { break; } $last = (int) $page[ count( $page ) - 1 ]['id']; } } if ( $policy['sms_days'] > 0 ) { ( new \Abientot\Repositories\RetainedSmsPrivacyRepository() )->export_rows( array(), 1 ); $table = $wpdb->prefix . 'abientot_sms_jobs'; $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ); if ( $wpdb->last_error ) { throw new \RuntimeException( 'privacy_read' ); } if ( $exists === $table ) { $out['sms'] = $this->checked( $wpdb->get_results( $wpdb->prepare( "SELECT id,state,result_at FROM {$wpdb->prefix}abientot_sms_jobs WHERE state IN ('accepted','rejected','cancelled','invalid') AND result_at<%s ORDER BY id LIMIT 100", $this->cutoff( $policy['sms_days'] ) ), ARRAY_A ) ); } ( new \Abientot\Repositories\RetainedSmsPrivacyRepository() )->export_rows( array(), 1, 'state' ); $states = $wpdb->prefix . 'abientot_sms_state'; $has = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $states ) ) ); if ( $wpdb->last_error ) { throw new \RuntimeException( 'privacy_read' ); } if ( $has === $states && $exists === $table ) { $cut = $this->cutoff( $policy['sms_days'] ); $out['sms_state'] = $this->checked( $wpdb->get_results( $wpdb->prepare( "SELECT s.appointment_id AS id,s.revision FROM {$wpdb->prefix}abientot_sms_state s INNER JOIN {$wpdb->prefix}abientot_appointments a ON a.id=s.appointment_id WHERE a.end_utc<%s AND NOT EXISTS(SELECT 1 FROM {$wpdb->prefix}abientot_sms_jobs j WHERE j.appointment_id=s.appointment_id AND (j.state NOT IN ('accepted','rejected','cancelled','invalid') OR j.result_at IS NULL OR j.result_at>=%s)) ORDER BY s.appointment_id LIMIT 100", $cut, $cut ), ARRAY_A ) ); } } if ( $policy['email_days'] > 0 && ( new \Abientot\Repositories\NotificationAttemptsRepository() )->present() ) { $out['email'] = $this->checked( $wpdb->get_results( $wpdb->prepare( "SELECT id,occurred_at FROM {$wpdb->prefix}abientot_notification_attempts WHERE occurred_at<%s ORDER BY id LIMIT 100", $this->cutoff( $policy['email_days'] ) ), ARRAY_A ) ); } $out['related_activity'] = array( 'sms' => 0, 'sms_state' => 0, 'email' => 0 ); if ( $out['customers'] ) { $ids = array_map( 'intval', array_column( $out['customers'], 'id' ) ); foreach ( array( 'sms' => 'sms_jobs', 'sms_state' => 'sms_state', 'email' => 'notification_attempts' ) as$kind => $suffix ) { $table = $wpdb->prefix . 'abientot_' . $suffix; $has = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ); if ( $wpdb->last_error ) { throw new \RuntimeException( 'privacy_read' ); } if ( $has !== $table ) { continue; } $exclude = $out[ $kind ] ? array_map( 'intval', array_column( $out[ $kind ], 'id' ) ) : array( 0 ); if ( $kind === 'sms' ) { $rows = $this->checked( $wpdb->get_results( $wpdb->prepare( "SELECT COUNT(*) AS count FROM {$wpdb->prefix}abientot_sms_jobs j INNER JOIN {$wpdb->prefix}abientot_appointments a ON a.id=j.appointment_id WHERE a.customer_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND j.id NOT IN (" . implode( ',', array_fill( 0, count( $exclude ), '%d' ) ) . ")", array_merge( $ids, $exclude ) ), ARRAY_A ) ); } elseif ( $kind === 'sms_state' ) { $rows = $this->checked( $wpdb->get_results( $wpdb->prepare( "SELECT COUNT(*) AS count FROM {$wpdb->prefix}abientot_sms_state j INNER JOIN {$wpdb->prefix}abientot_appointments a ON a.id=j.appointment_id WHERE a.customer_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND j.appointment_id NOT IN (" . implode( ',', array_fill( 0, count( $exclude ), '%d' ) ) . ")", array_merge( $ids, $exclude ) ), ARRAY_A ) ); } else { $rows = $this->checked( $wpdb->get_results( $wpdb->prepare( "SELECT COUNT(*) AS count FROM {$wpdb->prefix}abientot_notification_attempts j INNER JOIN {$wpdb->prefix}abientot_appointments a ON a.id=j.appointment_id WHERE a.customer_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND j.id NOT IN (" . implode( ',', array_fill( 0, count( $exclude ), '%d' ) ) . ")", array_merge( $ids, $exclude ) ), ARRAY_A ) ); } $out['related_activity'][ $kind ] = (int) $rows[0]['count']; } } return $out; } public function preview(): array { if ( ! self::permitted() ) { throw new \RuntimeException( 'privacy_permission' ); } if ( BookingLock::is_held() || ! BookingLock::acquire() ) { throw new \RuntimeException( 'privacy_lock' ); } try { $policy = self::policy(); $rows = $this->candidates( $policy ); $revision = hash( 'sha256', wp_json_encode( array( $policy, $rows ) ) ); $payload = array( 'policy' => $policy, 'rows' => $rows, 'revision' => $revision ); $payload['token'] = \Abientot\Import\Token::seal( 'privacy_retention', $payload, get_current_user_id() ); return $payload; } finally { BookingLock::release(); } } public function execute( string $token ): array { if ( ! self::permitted() ) { throw new \RuntimeException( 'privacy_permission' ); } $preview = \Abientot\Import\Token::open( $token, 'privacy_retention', get_current_user_id() ); if ( BookingLock::is_held() || ! BookingLock::acquire() ) { throw new \RuntimeException( 'privacy_lock' ); } global $wpdb; $counts = array( 'customers' => 0, 'sms' => 0, 'sms_state' => 0, 'email' => 0 ); $started = false; try { $policy = self::policy(); $rows = $this->candidates( $policy ); if ( ! hash_equals( $preview['revision'] ?? '', hash( 'sha256', wp_json_encode( array( $policy, $rows ) ) ) ) ) { throw new \DomainException( 'privacy_stale' ); } \Abientot\Support\TransactionGuard::prepare_write(); if ( $wpdb->query( 'START TRANSACTION' ) === false ) { throw new \RuntimeException( 'privacy_write' ); } $started = true; foreach ( array( 'sms' => 'sms_jobs', 'sms_state' => 'sms_state', 'email' => 'notification_attempts' ) as$kind => $table ) { foreach ( $rows[ $kind ] as$row ) { if ( $wpdb->delete( $wpdb->prefix . 'abientot_' . $table, array( ( $kind === 'sms_state' ? 'appointment_id' : 'id' ) => (int) $row['id'] ) ) !== 1 ) { throw new \RuntimeException( 'privacy_write' ); } $counts[ $kind ]++; } } if ( $wpdb->query( 'COMMIT' ) === false ) { throw new \RuntimeException( 'privacy_write' ); } $started = false; // Each customer is an atomic shared-erasure operation. No mail is dispatched. foreach ( $rows['customers'] as$row ) { if ( ! ( new PrivacyRepository() )->erase_customer( (int) $row['id'] ) ) { throw new \RuntimeException( 'privacy_write' ); } $counts['customers']++; } return $counts; } catch ( \Throwable $e ) { if ( $started ) { $wpdb->query( 'ROLLBACK' ); } throw $e; } finally { BookingLock::release(); } } }