> $exporters * @return array> */ public function exporters(array $exporters): array { $exporters['daytidy'] = [ 'exporter_friendly_name' => esc_html__('DayTidy bookings', 'daytidy'), 'callback' => [$this, 'export'], ]; return $exporters; } /** * @param array> $erasers * @return array> */ public function erasers(array $erasers): array { $erasers['daytidy'] = [ 'eraser_friendly_name' => esc_html__('DayTidy bookings', 'daytidy'), 'callback' => [$this, 'retain'], ]; return $erasers; } public function policyContent(): void { if (!function_exists('wp_add_privacy_policy_content')) return; $content = '

' . esc_html__( 'DayTidy stores booking and customer data, including contact details and booking selections, on this website so the site owner can manage bookings and provide customer support.', 'daytidy' ) . '

'; $content .= '

' . esc_html__( 'The site owner is responsible for deciding how long booking data is retained and for responding to requests to export, correct, or erase personal data. DayTidy does not automatically erase booking records through WordPress personal-data requests because those records may be required for active bookings, support, accounting, or other obligations.', 'daytidy' ) . '

'; $content .= '

' . wp_kses( sprintf( /* translators: %s: Webilia privacy policy URL. */ __('If an administrator voluntarily submits deactivation feedback, DayTidy sends the site URL, selected reason, and optional comments to Webilia. Read the Webilia Privacy Policy.', 'daytidy'), esc_url('https://webilia.com/privacy-policy/') ), ['a' => ['href' => []]] ) . '

'; $content .= '

' . esc_html__('DayTidy requests product announcements from Webilia using this site’s URL and the active DayTidy plugin basenames. Requests are cached and retried by the shared service. No booking or customer data is sent with announcements or deactivation feedback.', 'daytidy') . '

'; $content .= '

' . esc_html__('When an administrator connects this website with Webilia, DayTidy sends the site URL, product identifiers and versions, and connection verification data to Webilia to register eligible Pro access, which can consume a license seat. License-code activation and validation send the supplied code and site URL. Connected status and authorization are checked periodically while Pro is active. Pro installation and updates request an authorized package from Webilia and download it from its CDN. This website connection is shared with other Webilia products; disconnecting it affects those products. No booking or customer data is sent.', 'daytidy') . '

'; wp_add_privacy_policy_content(esc_html__('DayTidy', 'daytidy'), wp_kses_post(wpautop($content, false))); } /** * @return array */ public function export(string $email_address, int $page = 1): array { $email = sanitize_email($email_address); if ($email === '') return $this->exportResult([], true); $page = max(1, $page); $bookings = $this->matchingBookings($email, $page * self::PAGE_SIZE + 1); $page_bookings = array_slice($bookings, ($page - 1) * self::PAGE_SIZE, self::PAGE_SIZE); $items = array_merge($page === 1 ? $this->customerItems($email) : [], $this->bookingItems($page_bookings, $email)); $done = count($bookings) <= $page * self::PAGE_SIZE; if ($done) $this->clearMatchingBookings($email); return $this->exportResult($items, $done); } /** * @return array */ public function retain(string $email_address, int $page = 1): array { $email = sanitize_email($email_address); $has_records = $email !== '' && ($this->hasCustomer($email) || $this->hasBookings($email, $page)); if ($email !== '') $this->clearMatchingBookings($email); return [ 'items_removed' => false, 'items_retained' => $has_records, 'messages' => $has_records ? [ esc_html__( 'DayTidy booking records were retained. The site owner must review applicable booking, support, accounting, and privacy obligations before deleting or anonymizing these records.', 'daytidy' ), ] : [], 'done' => true, ]; } /** * @return array> */ private function customerItems(string $email): array { $db = new Db(); $customer = $db->getRow($db->prepare( 'SELECT id, first_name, last_name, email, created_at FROM #__daytidy_customers WHERE email = %s', $email )); if (!$customer) return []; return [[ 'group_id' => 'daytidy-customer', 'group_label' => esc_html__('DayTidy customer', 'daytidy'), 'item_id' => 'daytidy-customer-' . (int) $customer->id, 'data' => [ ['name' => esc_html__('First name', 'daytidy'), 'value' => (string) $customer->first_name], ['name' => esc_html__('Last name', 'daytidy'), 'value' => (string) $customer->last_name], ['name' => esc_html__('Email address', 'daytidy'), 'value' => (string) $customer->email], ['name' => esc_html__('Created', 'daytidy'), 'value' => (string) $customer->created_at], ], ]]; } /** * @param array $bookings * @return array> */ private function bookingItems(array $bookings, string $email): array { $primary_booking_ids = []; foreach ($bookings as $post) { $booking_id = (int) $post->ID; if ($this->isPrimaryBooker($booking_id, $email)) $primary_booking_ids[$booking_id] = true; } $payment_histories = count($primary_booking_ids) ? $this->paymentTransactionHistories(array_keys($primary_booking_ids)) : []; $items = []; foreach ($bookings as $post) { $booking_id = (int) $post->ID; $primary = isset($primary_booking_ids[$booking_id]); $form = $this->personalDataValue($primary ? get_post_meta($booking_id, 'daytidy_form', true) : $this->matchingValues(get_post_meta($booking_id, 'daytidy_form', true), $email)); $participants = $this->personalDataValue($this->matchingValues(get_post_meta($booking_id, 'daytidy_participants', true), $email)); $data = [ [ 'name' => esc_html__('Booking', 'daytidy'), 'value' => $primary ? get_the_title($booking_id) : esc_html__('Booking record', 'daytidy'), ], ['name' => esc_html__('Reference', 'daytidy'), 'value' => (string) get_post_meta($booking_id, 'daytidy_ref', true)], ['name' => esc_html__('Submitted', 'daytidy'), 'value' => (string) get_post_meta($booking_id, 'daytidy_submitted_at', true)], ]; if ($primary) { array_splice($data, 1, 0, [ ['name' => esc_html__('Email address', 'daytidy'), 'value' => (string) get_post_meta($booking_id, 'daytidy_email', true)], ['name' => esc_html__('First name', 'daytidy'), 'value' => (string) get_post_meta($booking_id, 'daytidy_first_name', true)], ['name' => esc_html__('Last name', 'daytidy'), 'value' => (string) get_post_meta($booking_id, 'daytidy_last_name', true)], ]); } if ($form !== '') $data[] = ['name' => esc_html__('Booking form data', 'daytidy'), 'value' => $form]; if ($participants !== '') $data[] = ['name' => esc_html__('Participants', 'daytidy'), 'value' => $participants]; if ($primary) { foreach ($this->bookingSelections($booking_id, $payment_histories[$booking_id] ?? []) as $selection) $data[] = $selection; } $items[] = [ 'group_id' => 'daytidy-bookings', 'group_label' => esc_html__('DayTidy bookings', 'daytidy'), 'item_id' => 'daytidy-booking-' . $booking_id, 'data' => $data, ]; } return $items; } /** * @return array */ private function matchingBookings(string $email, int $required = 1): array { $cache_key = 'daytidy_privacy_matches_' . md5(strtolower($email)); $cache = function_exists('get_transient') ? get_transient($cache_key) : false; $cache = is_array($cache) ? $cache : []; $ids = isset($cache['ids']) && is_array($cache['ids']) ? array_map('absint', $cache['ids']) : []; $candidate_page = isset($cache['candidate_page']) ? max(1, absint($cache['candidate_page'])) : 1; $complete = !empty($cache['complete']); while (!$complete && count($ids) < $required) { $query = $this->matchingCandidateBookings($email, $candidate_page); foreach ($query->posts as $post) { $booking_id = (int) $post->ID; if ($this->bookingMatchesEmail($booking_id, $email)) $ids[] = $booking_id; } ++$candidate_page; $complete = count($query->posts) < self::MATCH_CANDIDATE_PAGE_SIZE; } $ids = array_values(array_unique(array_filter($ids))); if (function_exists('set_transient')) { set_transient($cache_key, [ 'ids' => $ids, 'candidate_page' => $candidate_page, 'complete' => $complete, ], self::MATCH_CACHE_TTL); } return array_map(static function(int $booking_id): object { return (object) ['ID' => $booking_id]; }, $ids); } private function matchingCandidateBookings(string $email, int $candidate_page): \WP_Query { $query = new \WP_Query([ 'post_type' => Booking::PT, 'post_status' => self::BOOKING_POST_STATUSES, 'posts_per_page' => self::MATCH_CANDIDATE_PAGE_SIZE, 'paged' => $candidate_page, 'meta_query' => [ 'relation' => 'OR', ['key' => 'daytidy_email', 'value' => $email], ['key' => 'daytidy_form', 'value' => $email, 'compare' => 'LIKE'], ['key' => 'daytidy_participants', 'value' => $email, 'compare' => 'LIKE'], ], 'orderby' => 'ID', 'order' => 'ASC', 'no_found_rows' => true, ]); return $query; } private function clearMatchingBookings(string $email): void { if (function_exists('delete_transient')) { delete_transient('daytidy_privacy_matches_' . md5(strtolower($email))); } } private function bookingMatchesEmail(int $booking_id, string $email): bool { return $this->isPrimaryBooker($booking_id, $email) || $this->containsEmail(get_post_meta($booking_id, 'daytidy_form', true), $email) || $this->containsEmail(get_post_meta($booking_id, 'daytidy_participants', true), $email); } private function isPrimaryBooker(int $booking_id, string $email): bool { return strtolower((string) get_post_meta($booking_id, 'daytidy_email', true)) === strtolower($email); } /** * @param mixed $value */ private function containsEmail($value, string $email): bool { if (is_scalar($value)) return strtolower((string) $value) === strtolower($email); if (!is_array($value)) return false; foreach ($value as $item) { if ($this->containsEmail($item, $email)) return true; } return false; } /** * @param mixed $value * @return mixed[] */ private function matchingValues($value, string $email): array { if (!is_array($value)) return []; $matches = []; foreach ($value as $key => $item) { if ($this->containsEmail($item, $email)) $matches[$key] = $item; } return $matches; } /** * @param array> $payment_history * @return array */ private function bookingSelections(int $booking_id, array $payment_history = []): array { $fields = [ 'daytidy_session' => esc_html__('Session', 'daytidy'), 'daytidy_resources' => esc_html__('Resources', 'daytidy'), 'daytidy_coupon' => esc_html__('Coupon', 'daytidy'), 'daytidy_pricing' => esc_html__('Pricing', 'daytidy'), 'daytidy_total' => esc_html__('Total', 'daytidy'), 'daytidy_booking_status' => esc_html__('Booking status', 'daytidy'), 'daytidy_payment_status' => esc_html__('Payment status', 'daytidy'), ]; $fields['daytidy_gateway_ref_id'] = esc_html__('Payment reference', 'daytidy'); $data = []; $draft_selections = $this->bookingDraftSelections($booking_id); if ($draft_selections !== '') $data[] = [ 'name' => esc_html__('Booking selections', 'daytidy'), 'value' => $draft_selections, ]; foreach ($fields as $key => $label) { $value = $this->personalDataValue(get_post_meta($booking_id, $key, true)); if ($value !== '') $data[] = ['name' => $label, 'value' => $value]; } $transaction = $this->transactionData($booking_id); if ($transaction !== '') $data[] = [ 'name' => esc_html__('Payment transaction', 'daytidy'), 'value' => $transaction, ]; if (count($payment_history)) $data[] = [ 'name' => esc_html__('Payment history', 'daytidy'), 'value' => $this->paymentHistoryData($payment_history), ]; return $data; } private function transactionData(int $booking_id): string { $transaction = get_post_meta($booking_id, 'daytidy_transaction', true); if (!is_string($transaction) || $transaction === '') return ''; $data = json_decode($transaction, true); if (!is_array($data)) $data = json_decode(stripslashes($transaction), true); if (!is_array($data)) return ''; $fields = array_intersect_key($data, array_flip([ 'gateway', 'ref_id', 'gateway_ref_id', 'total', 'payable', 'currency', 'pricing', 'coupon', ])); return $this->personalDataValue($fields); } /** * @param int[] $booking_ids * @return array>> */ protected function paymentTransactionHistories(array $booking_ids): array { if (!count($booking_ids)) return []; return (new PaymentTransactionLedger())->histories($booking_ids); } /** * @param array> $history */ private function paymentHistoryData(array $history): string { $events = []; $fields = array_flip([ 'ref_id', 'gateway', 'gateway_name', 'gateway_ref_id', 'event_type', 'status', 'amount', 'payable_amount', 'currency', 'coupon_codes', 'context', 'created_at', ]); foreach ($history as $event) { $event = array_intersect_key($event, $fields); $context = $event['context'] ?? null; if (is_string($context)) { $decoded_context = json_decode($context, true); if (is_array($decoded_context)) $event['context'] = $decoded_context; } $events[] = $event; } return $this->personalDataValue($events); } private function bookingDraftSelections(int $booking_id): string { $draft = get_post_meta($booking_id, 'daytidy_booking', true); if (!is_string($draft)) return ''; $data = json_decode($draft, true); if (!is_array($data)) return ''; $selections = array_intersect_key($data, array_flip([ 'sessions', 'option_sessions', 'options', 'resources', 'coupon', 'method', 'date', 'time', ])); return $this->personalDataValue($selections); } /** * @param mixed $value */ private function personalDataValue($value): string { if (is_scalar($value)) return (string) $value; if (!is_array($value) || !count($value)) return ''; $encoded = wp_json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); return is_string($encoded) ? $encoded : ''; } private function hasCustomer(string $email): bool { $db = new Db(); return (bool) $db->getVar($db->prepare( 'SELECT id FROM #__daytidy_customers WHERE email = %s LIMIT 1', $email )); } private function hasBookings(string $email, int $page): bool { unset($page); return count($this->matchingBookings($email)) > 0; } /** * @param array> $items * @return array */ private function exportResult(array $items, bool $done): array { return [ 'data' => $items, 'done' => $done, ]; } }