__('Identity is not unambiguous. An authorized operator must verify the person before processing.','abece-booking'), 'active_booking'=>__('Active or future bookings require an operational decision before erasure. No booking was cancelled.','abece-booking'), 'membership'=>__('Active membership or an unpaid membership fee requires review before erasure.','abece-booking'), 'financial'=>__('Financial records or amounts require individual retention review. Prices, debts, ledger entries and refunds were preserved.','abece-booking'), 'restriction'=>__('An unresolved customer restriction requires an operator’s documented review.','abece-booking'), 'audit'=>__('Historical snapshots, staff audit links or mixed notes remain for individual review. The remaining data is not claimed to be anonymous.','abece-booking'), 'account'=>__('The WordPress account is managed separately. Review its continued purpose without breaking financial or staff audit links.','abece-booking')];} public function holds(array $i):array { $u=R::in($i['users']);$m=R::in($i['members']);$e=R::in($i['contacts']);$p=$this->r->prefix;$reasons=[];$related=$this->r->related($i);$own=$this->r->own($i);$now=current_time('mysql',true); if($i['ambiguous'])return ['ambiguous']; if($this->r->count('bookings','('.$related.") AND ends_at>=%s AND status NOT IN ('cancelled','voided')",[$now]))$reasons[]='active_booking'; foreach($this->r->rows('booking_series',"status='active' AND ends_on>=%s",[current_datetime()->format('Y-m-d')],'id,template') as $series){$t=json_decode($series['template'],true)??[];if(array_intersect($i['users'],[(int)($t['customer_user_id']??0)])||array_intersect($i['members'],array_merge((array)($t['member_ids']??[]),[(int)($t['customer_member_id']??0)]))||array_intersect($i['contacts'],[(int)($t['external_contact_id']??0)]))$reasons[]='active_booking';} if($this->r->count('memberships',"member_id IN ($m) AND ((status IN ('active','pending') AND (COALESCE(valid_to,(SELECT ends_on FROM {$p}seasons WHERE id=season_id)) IS NULL OR COALESCE(valid_to,(SELECT ends_on FROM {$p}seasons WHERE id=season_id))>=%s)) OR (membership_fee_amount>0 AND paid_at IS NULL))",[current_datetime()->format('Y-m-d')]))$reasons[]='membership'; if($this->r->count('bookings','('.$own.') AND (final_amount<>0 OR cancellation_fee<>0 OR no_show_fee<>0)')||$this->r->count('credit_accounts',"user_id IN ($u)")||$this->r->count('payment_transactions',"user_id IN ($u) OR booking_id IN (SELECT id FROM {$p}bookings WHERE $own)"))$reasons[]='financial'; if($this->r->count('customer_restrictions',"(wp_user_id IN ($u) OR member_id IN ($m) OR external_contact_id IN ($e)) AND revoked_at IS NULL AND (ends_at IS NULL OR ends_at>=%s)",[$now]))$reasons[]='restriction'; return array_values(array_unique($reasons)); } public function erase(string $email,int $page=1,int $request=0):array { if(!current_user_can('erase_others_personal_data')||!is_email($email)||$page<1)return ['items_removed'=>false,'items_retained'=>true,'messages'=>[__('You do not have permission to manage privacy requests.','abece-booking')],'done'=>true]; $identity=$this->identities->resolve($email,$request);$key='abece_booking_privacy_erase_'.hash_hmac('sha256',$email.'|'.$request.'|'.get_current_user_id(),wp_salt('auth'));$plan=get_transient($key);$approval=$request?hash('sha256',wp_json_encode(get_post_meta($request,'abece_booking_privacy_identity',true))):''; if(!is_array($plan)||($plan['expires']??0)<=time()||($plan['approval']??'')!==$approval){if($page!==1)return ['items_removed'=>false,'items_retained'=>true,'messages'=>[__('The privacy request expired. Restart it from the first page.','abece-booking')],'done'=>true];$plan=['approval'=>$approval,'identity'=>$identity,'fingerprint'=>$this->identities->fingerprint($identity),'bookings'=>array_map('intval',array_column($this->r->rows('bookings',$this->r->own($identity),[],'id'),'id')),'expires'=>time()+DAY_IN_SECONDS];set_transient($key,$plan,DAY_IN_SECONDS);} if($page>1&&$plan['expires']<=time())return ['items_removed'=>false,'items_retained'=>true,'messages'=>[__('The privacy request expired. Restart it from the first page.','abece-booking')],'done'=>true]; return $this->r->exclusive(function()use($plan,$page,$email,$key){$i=$plan['identity'];$changed=!hash_equals($plan['fingerprint']??'', $this->identities->fingerprint($i));$holds=$changed?['ambiguous']:$this->holds($i);if($holds){$this->review($i,$holds,'request');return ['items_removed'=>false,'items_retained'=>true,'messages'=>array_values(array_intersect_key(self::reasons(),array_flip($holds))),'done'=>true];} $ids=array_slice($plan['bookings'],($page-1)*50,50);$removed=false;$retained=[]; foreach($ids as $id){$b=$this->r->rows('bookings','id=%d',[$id])[0]??null;if(!$b)continue; if(!$this->r->count('bookings','id=%d AND '.$this->r->own($i),[$id]))continue; // Amounts, statuses, occurrences, participants and all financial snapshots stay intact. $removed=$this->r->update('bookings',$id,['customer_user_id'=>null,'customer_member_id'=>null,'external_contact_id'=>null,'external_customer_label'=>null,'external_contact_snapshot'=>null,'customer_note'=>null])||$removed; if($b['series_id']||$b['pricing_snapshot']||$b['cancellation_policy_snapshot']||$b['admin_note']||$this->r->count('booking_history','booking_id=%d',[$id]))$retained[]='audit'; } $done=$page*50>=count($plan['bookings']); if($done){$result=$this->profiles($i,$email,$plan['bookings']);$removed=$result['removed']||$removed;$retained=array_merge($retained,$result['retained']);} $retained=array_values(array_unique($retained));if($done){$plan['fingerprint']=$this->identities->fingerprint($i);set_transient($key,$plan,DAY_IN_SECONDS);}$this->review($i,$retained,'request');return ['items_removed'=>$removed,'items_retained'=>(bool)$retained,'messages'=>array_values(array_intersect_key(self::reasons(),array_flip($retained))),'done'=>$done]; }); } private function profiles(array $i,string $email,array $bookings):array { $removed=false;$retained=[];$m=R::in($i['members']);$u=R::in($i['users']); foreach($i['members'] as $id){$removed=$this->r->update('members',$id,['first_name'=>'[privacy-erased]','last_name'=>'','email'=>null,'phone'=>null,'date_of_birth'=>null,'member_number'=>null,'wp_user_id'=>null,'is_active'=>0])||$removed; // Preserve participant counts / member classification and historical membership statistics. foreach($this->r->rows('booking_participants','member_id=%d',[$id],'id') as $row)$removed=$this->r->update('booking_participants',(int)$row['id'],['name'=>null])||$removed; if($this->r->count('booking_participants','member_id=%d',[$id])||$this->r->count('memberships','member_id=%d',[$id]))$retained[]='audit'; } foreach($i['contacts'] as $id){if(!$this->r->count('bookings','external_contact_id=%d',[$id])&&!$this->r->count('customer_restrictions','external_contact_id=%d',[$id])&&!$this->r->count('booking_series',"JSON_VALID(template) AND JSON_EXTRACT(template,'$.external_contact_id')=%d",[$id]))$removed=$this->r->delete('external_contacts',$id)||$removed;else $retained[]='audit';} foreach($i['users'] as $uid){foreach(['abece_booking_phone','abece_booking_onboarding_dismissed','abece_booking_privacy_acknowledgement','abece_booking_privacy_request_rate'] as $key)$removed=\ABeCe\Booking\Support\LegacyIdentifiers::delete_user_metadata($uid,$key)||$removed;if(get_userdata($uid))$retained[]='account';if(\ABeCe\Booking\Support\LegacyIdentifiers::user_metadata($uid,'abece_booking_membership_link_audit')||\ABeCe\Booking\Support\LegacyIdentifiers::user_metadata($uid,'abece_booking_membership_batch_audit'))$retained[]='audit';} foreach($this->r->rows('notifications','(user_id IN ('.$u.') OR (user_id IS NULL AND recipient=%s AND booking_id IN ('.R::in($bookings).'))) AND status IN (\'sent\',\'failed\')',[$email],'id') as $n)$removed=$this->r->update('notifications',(int)$n['id'],['user_id'=>null,'recipient'=>'privacy-erased@example.invalid','subject'=>null,'last_error'=>null])||$removed; foreach(['bookings'=>'created_by_user_id','booking_history'=>'performed_by_user_id','booking_series'=>'created_by_user_id','resource_blocks'=>'created_by_user_id','credit_transactions'=>'created_by_user_id'] as $table=>$field)if($this->r->count($table,$field.' IN ('.$u.')'))$retained[]='audit'; if($this->r->count('customer_restrictions',"member_id IN ($m) OR wp_user_id IN ($u) OR external_contact_id IN (".R::in($i['contacts']).')'))$retained[]='audit'; return ['removed'=>$removed,'retained'=>$retained]; } public function review(array $i,array $reasons,string $origin):void { $key=hash_hmac('sha256',wp_json_encode([$i['users'],$i['members'],$i['contacts']]),wp_salt('auth'));$reviews=(array)get_option('abece_booking_privacy_reviews',[]); if(!$reasons){unset($reviews[$key]);}else{$previous=$reviews[$key]??[];$reviews[$key]=array_replace($previous,['reasons'=>array_values(array_unique($reasons)),'identity'=>array_intersect_key($i,array_flip(['users','members','contacts'])),'origin'=>$origin,'checked_at'=>time(),'review_after'=>$previous['review_after']??time()+90*DAY_IN_SECONDS]);} update_option('abece_booking_privacy_reviews',$reviews,false); } public function retentionCandidate(string $table,int $id,bool $apply):array { $row=$this->r->rows($table,'id=%d',[$id])[0]??null;if(!$row)return ['removed'=>false,'reasons'=>[]];$i=PrivacyIdentityService::empty(); if($table==='bookings'){$i['users']=array_filter([(int)$row['customer_user_id']]);$i['members']=array_filter([(int)$row['customer_member_id']]);$i['contacts']=array_filter([(int)$row['external_contact_id']]);} elseif($table==='members'){$i['members']=[$id];$i['users']=array_filter([(int)$row['wp_user_id']]);}elseif($table==='external_contacts')$i['contacts']=[$id]; $holds=$this->holds($i); if($holds){if($apply)$this->review($i,$holds,'retention');return ['removed'=>false,'reasons'=>$holds];} if(!$apply)return ['removed'=>false,'reasons'=>[]];$removed=false; if($table==='bookings')$removed=$this->r->update('bookings',$id,['customer_user_id'=>null,'customer_member_id'=>null,'external_contact_id'=>null,'external_customer_label'=>null,'external_contact_snapshot'=>null,'customer_note'=>null]); elseif($table==='members')$removed=$this->r->update('members',$id,['first_name'=>'[privacy-erased]','last_name'=>'','email'=>null,'phone'=>null,'member_number'=>null,'date_of_birth'=>null,'wp_user_id'=>null]); elseif($table==='external_contacts'){if($this->r->count('bookings','external_contact_id=%d',[$id])||$this->r->count('customer_restrictions','external_contact_id=%d',[$id])||$this->r->count('booking_series',"JSON_VALID(template) AND JSON_EXTRACT(template,'$.external_contact_id')=%d",[$id]))return ['removed'=>false,'reasons'=>['audit']];$removed=$this->r->delete('external_contacts',$id);} return ['removed'=>$removed,'reasons'=>($table==='bookings'&&($row['pricing_snapshot']||$row['admin_note']))?['audit']:[]]; } }