*/ private static array $refundChargeProcessed = []; /** * Constructor */ public function __construct() { parent::__construct([ 'PT' => Booking::PT, 'request_key' => 'daytidy', 'nonce_name' => Booking::NONCE, ]); } public function init(): void { parent::init(); // Sortable Columns add_filter('manage_edit-' . $this->PT . '_sortable_columns', [$this, 'sortable']); add_action('pre_get_posts', [$this, 'filterQuery']); // Bulk Actions add_action('admin_footer-edit.php', [$this, 'bulkActions']); add_action('load-edit.php', [$this, 'bulkPerform']); // Booking Updated add_action('post_updated', [$this, 'postUpdated'], 10, 2); // Slug is generated internally and is not useful for booking records. add_action('add_meta_boxes_' . $this->PT, [$this, 'removeSlugMetabox'], 100); // Before Booking Delete add_action('before_delete_post', [$this, 'beforeDelete'], 10, 2); // Remove Booking Scheduled add_action('edit_post', [$this, 'removeScheduled'], 10, 2); // Details Metabox (new DetailsMetabox($this))->init(); // Status Metabox (new StatusMetabox($this))->init(); } public function args(): array { return apply_filters('DayTidy/F__DayTidy/Posts/Booking__args/args', [ 'labels' => $this->labels( esc_html__('Booking', 'daytidy'), esc_html__('Bookings', 'daytidy') ), 'public' => false, 'show_ui' => true, 'show_in_menu' => false, 'show_in_rest' => false, 'show_in_admin_bar' => false, 'has_archive' => false, 'exclude_from_search' => true, 'publicly_queryable' => false, 'supports' => ['title'], 'menu_icon' => 'dashicons-text-page', 'menu_position' => 28, 'capabilities' => Capabilities::postTypeCapabilities(Capabilities::MANAGE_BOOKINGS), 'map_meta_cap' => false, ]); } public function removeSlugMetabox(): void { remove_meta_box('slugdiv', $this->PT, 'normal'); } public function columns(array $columns): array { unset($columns['title']); unset($columns['date']); unset($columns['author']); $columns['title'] = esc_html__('Title', 'daytidy'); $columns['ref'] = esc_html__('Ref', 'daytidy'); $columns['options'] = esc_html__('Options', 'daytidy'); $columns['total'] = esc_html__('Total', 'daytidy'); $columns['status'] = esc_html__('Status', 'daytidy'); $columns['dates'] = esc_html__('Date', 'daytidy'); $columns['submitted_at'] = esc_html__('Order Time', 'daytidy'); return $columns; } public function content(string $column_name, int $post_id): void { // Booking Entity $booking = $this->entity($post_id); if ($column_name === 'ref') { echo esc_html($booking->meta_get('daytidy_ref', 'N/A')); $email = sanitize_email((string) $booking->meta_get('daytidy_email', '')); if (is_email($email)) { $name = trim(sanitize_text_field((string) $booking->meta_get('daytidy_first_name', '')) . ' ' . sanitize_text_field((string) $booking->meta_get('daytidy_last_name', ''))); echo ''; } } else if ($column_name === 'options') { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- DayTidy Kses sanitizes the rendered options summary. echo Kses::element($booking->optionsSummary()); } else if ($column_name === 'total') { echo esc_html($booking->total()->render()) . '
' . esc_html($booking->getGateway()->name()); } else if ($column_name === 'status') { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- DayTidy Kses sanitizes the rendered status badge markup. echo Kses::element($booking->statusBadges()); } else if ($column_name === 'submitted_at') { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- DayTidy Kses preserves the rendered date markup. echo Kses::element(DateTime::render($booking->meta_get('daytidy_submitted_at', ''), DateTimeFormat::wp(), $booking->offering()->getTimeZone())); } else if ($column_name === 'dates') { $sessions = $booking->getData()->getAllSessions(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- DayTidy Kses preserves the rendered date markup and separators. echo Kses::element(DateTime::render($sessions, DateTimeFormat::wp(), $booking->offering()->getTimeZone(), '
')); } } /** * @param mixed[] $columns * @return mixed[] */ public function sortable(array $columns): array { $columns['submitted_at'] = 'daytidy_submitted_at'; $columns['dates'] = 'dates'; return $columns; } /** * @param WP_Query $query */ public function filterQuery(WP_Query $query): void { if (!is_admin() || !$query->is_main_query() || $query->get('post_type') !== $this->PT) return; // Request $req = $_REQUEST; /** * Order */ $orderby = $query->get('orderby'); if ($orderby === '' || $orderby === 'daytidy_submitted_at') { $query->set('meta_key', 'daytidy_submitted_at'); $query->set('orderby', 'daytidy_submitted_at'); $order = $query->get('order'); $query->set('order', $order ?: 'desc'); } else if ($orderby === 'dates') { $query->set('meta_key', 'daytidy_session'); $query->set('orderby', 'daytidy_session'); $order = $query->get('order'); $query->set('order', $order ?: 'desc'); } /** * Filter */ // Meta Query $meta_query = []; // Booking Statuses foreach ([StateService::META_BOOKING_STATUS, StateService::META_PAYMENT_STATUS] as $key) { if (isset($req[$key]) && trim($req[$key]) !== '') { $meta_query[] = [ 'key' => $key, 'value' => sanitize_key((string) $req[$key]), 'compare' => '=', 'type' => 'char', ]; } } // Offering if (isset($req['daytidy_offering']) && trim($req['daytidy_offering']) !== '') { $meta_query[] = [ 'key' => 'daytidy_offering_id', 'value' => sanitize_text_field($req['daytidy_offering']), 'compare' => '=', 'type' => 'numeric', ]; } // Apply Meta Query if (count($meta_query)) $query->set('meta_query', $meta_query); } public function filters(string $post_type): void { if ($post_type !== $this->PT) return; // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- All generated filter controls are passed through DayTidy Kses below. // Request $req = $_REQUEST; // Offerings // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- DayTidy Kses sanitizes the generated filter control markup. echo Kses::form(Form::offerings([ 'name' => 'daytidy_offering', 'id' => 'daytidy_offering', 'show_empty' => true, 'empty_label' => esc_html__('Offering', 'daytidy'), 'value' => isset($req['daytidy_offering']) && trim($req['daytidy_offering']) !== '' ? (int) $req['daytidy_offering'] : '', ])); // Booking Status // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- DayTidy Kses sanitizes the generated filter control markup. echo Kses::form(Form::dropdown([ 'class' => 'wui-select daytidy-select', 'name' => StateService::META_BOOKING_STATUS, 'id' => StateService::META_BOOKING_STATUS, 'show_empty' => true, 'empty_label' => esc_html__('Booking Status', 'daytidy'), 'options' => BookingStatus::options(), 'value' => isset($req[StateService::META_BOOKING_STATUS]) && trim($req[StateService::META_BOOKING_STATUS]) !== '' ? sanitize_key($req[StateService::META_BOOKING_STATUS]) : '', ])); // Payment Status // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- DayTidy Kses sanitizes the generated filter control markup. echo Kses::form(Form::dropdown([ 'class' => 'wui-select daytidy-select', 'name' => StateService::META_PAYMENT_STATUS, 'id' => StateService::META_PAYMENT_STATUS, 'show_empty' => true, 'empty_label' => esc_html__('Payment Status', 'daytidy'), 'options' => PaymentStatus::options(), 'value' => isset($req[StateService::META_PAYMENT_STATUS]) && trim($req[StateService::META_PAYMENT_STATUS]) !== '' ? sanitize_key($req[StateService::META_PAYMENT_STATUS]) : '', ])); // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped } public function bulkActions(): void { global $post_type; if ($post_type !== $this->PT) return; echo ''; } public function bulkPerform(): void { // WordPress List Table $wp_list_table = _get_list_table('WP_Posts_List_Table'); // Check Requested Action $action = $wp_list_table->current_action(); if (!$action) return; $post_type = isset($_REQUEST['post_type']) ? sanitize_text_field(wp_unslash($_REQUEST['post_type'])) : 'post'; if ($post_type !== $this->PT) return; // Security Referer check_admin_referer('bulk-posts'); // Post IDs $post_ids = isset($_REQUEST['post']) && is_array($_REQUEST['post']) && count($_REQUEST['post']) ? array_map('sanitize_text_field', wp_unslash($_REQUEST['post'])) : []; // Booking $booking = new \DayTidy\Booking\Booking( Config::general() ); switch ($action) { case 'cancel': foreach ($post_ids as $post_id) $booking->cancel($post_id, [ 'source' => 'bulk', 'actor' => 'admin', ]); break; case 'confirm': foreach ($post_ids as $post_id) $booking->confirm($post_id); break; case 'pending': foreach ($post_ids as $post_id) $booking->pending($post_id); break; case 'decline': foreach ($post_ids as $post_id) $booking->decline($post_id); break; case 'paid': foreach ($post_ids as $post_id) $booking->paid($post_id); break; case 'unpaid': foreach ($post_ids as $post_id) $booking->unpaid($post_id); break; case 'refund': foreach ($post_ids as $post_id) $booking->refund($post_id, ['source' => 'bulk']); break; case 'payment_pending': foreach ($post_ids as $post_id) $booking->paymentPending($post_id, ['source' => 'bulk']); break; case 'payment_failed': foreach ($post_ids as $post_id) $booking->paymentFailed($post_id, ['source' => 'bulk']); break; case 'payment_not_required': foreach ($post_ids as $post_id) $booking->paymentNotRequired($post_id, ['source' => 'bulk']); break; case 'complete': foreach ($post_ids as $post_id) $booking->complete($post_id, ['source' => 'bulk']); break; case 'fail': foreach ($post_ids as $post_id) $booking->fail($post_id, ['source' => 'bulk']); break; case 'expire': foreach ($post_ids as $post_id) $booking->expire($post_id, ['source' => 'bulk']); break; default: return; } wp_safe_redirect('edit.php?post_type=' . $this->PT); exit; } public function store(Post $entity, array $data): void { // Booking $booking = new \DayTidy\Booking\Booking( Config::general() ); // Post ID $id = $entity->id(); $this->storeManualEdit($entity, $data); // New Status $new_booking_status = isset($data['booking_status']) ? sanitize_key((string) $data['booking_status']) : ''; $new_payment_status = isset($data['payment_status']) ? PaymentStatus::normalize((string) $data['payment_status']) : ''; $refund_args = $this->refundArgs($data); $cancellation_context = $this->cancellationContext($data); $states = new StateService(); $admin_context = ['source' => 'admin']; $current_booking_status = $states->status($id, StateService::DIMENSION_BOOKING); $current_payment_status = $states->status($id, StateService::DIMENSION_PAYMENT); if ($new_booking_status === BookingStatus::COMPLETED && $current_booking_status !== BookingStatus::CONFIRMED && PaymentStatus::canSatisfyCompletionAfterTransition($current_payment_status, $new_payment_status) ) { $booking->confirm($id, 'manual', $admin_context); } // Payment $current = $current_payment_status; $charge_refund_available = (new BookingEntity($id))->canRefundCharge(); $partial_refund_amount_requested = $new_payment_status === PaymentStatus::PARTIALLY_REFUNDED && (float) $refund_args['amount'] > 0 && (!$charge_refund_available || $refund_args['charge']); $refund_requested = $new_payment_status === PaymentStatus::REFUNDED || $partial_refund_amount_requested; $charge_requested = $refund_requested && $refund_args['charge']; $charge_attempted = $charge_requested && !$this->refundChargeProcessed($id); $manual_partial_refund_requested = $partial_refund_amount_requested && !$refund_args['charge']; if ($charge_attempted) $this->markRefundChargeProcessed($id); $refund_args['charge'] = $charge_attempted; if ($new_payment_status && ($current !== $new_payment_status || ($refund_requested && $charge_attempted) || $manual_partial_refund_requested)) { if ($new_payment_status === PaymentStatus::PAID) $booking->paid($id, $admin_context); else if ($refund_requested) { $result = $booking->refund($id, $refund_args); $this->refundNotice($result, $refund_args); } else if ($new_payment_status === PaymentStatus::PENDING) $booking->paymentPending($id, $admin_context); else if ($new_payment_status === PaymentStatus::FAILED) $booking->paymentFailed($id, $admin_context); else if ($new_payment_status === PaymentStatus::NOT_REQUIRED) $booking->paymentNotRequired($id, $admin_context); else if ($new_payment_status === PaymentStatus::PARTIALLY_REFUNDED) $booking->partiallyRefunded($id, $admin_context); else $booking->unpaid($id, $admin_context); } // Booking $current_booking_status = $states->status($id, StateService::DIMENSION_BOOKING); if ($new_booking_status && $new_booking_status !== $current_booking_status) { if ($new_booking_status === BookingStatus::CONFIRMED) $booking->confirm($id, 'manual', $admin_context); else if ($new_booking_status === BookingStatus::DECLINED) $booking->decline($id, $admin_context); else if ($new_booking_status === BookingStatus::CANCELLED) $booking->cancel($id, $cancellation_context); else if ($new_booking_status === BookingStatus::COMPLETED) $booking->complete($id, $admin_context); else if ($new_booking_status === BookingStatus::FAILED) $booking->fail($id, $admin_context); else if ($new_booking_status === BookingStatus::EXPIRED) $booking->expire($id, $admin_context); else $booking->pending($id, $admin_context); } $this->storeCancellationReason($id, $data, $cancellation_context, $states); // Modules & Third Parties do_action('DayTidy/A__DayTidy/Posts/Booking__store/data', $entity, $data, $this); } /** * @param mixed[] $data */ private function storeManualEdit(Post $entity, array $data): void { $manual = isset($data['manual_edit']) && is_array($data['manual_edit']) ? $data['manual_edit'] : []; if (!count($manual)) return; $id = $entity->id(); $first_name = sanitize_text_field((string) ($manual['first_name'] ?? '')); $last_name = sanitize_text_field((string) ($manual['last_name'] ?? '')); $email = sanitize_email((string) ($manual['email'] ?? '')); if (!is_email($email)) { FlashMessage::error(esc_html__('Customer email is invalid.', 'daytidy')); return; } $customer_id = Customer::register([ 'first_name' => $first_name, 'last_name' => $last_name, 'email' => $email, 'wp_registration' => false, ]); update_post_meta($id, 'daytidy_customer_id', $customer_id); update_post_meta($id, 'daytidy_email', $email); update_post_meta($id, 'daytidy_first_name', $first_name); update_post_meta($id, 'daytidy_last_name', $last_name); update_post_meta($id, 'daytidy_admin_note', sanitize_textarea_field((string) ($manual['admin_note'] ?? ''))); global $wpdb; $wpdb->update($wpdb->posts, [ 'post_author' => $customer_id, 'post_title' => $email . ' - ' . trim($first_name . ' ' . $last_name), ], [ 'ID' => $id, ]); clean_post_cache($id); $booking = new BookingEntity($id); $draft = $booking->getData(); $form = $draft->getForm(); $contact = [ 'first_name' => $first_name, 'last_name' => $last_name, 'email' => $email, ]; $form['_manual_first_name'] = $first_name; $form['_manual_last_name'] = $last_name; $form['_manual_email'] = $email; [$fields] = $booking->offering()->getBookingFields(); $booking_fields = isset($fields['booking']) && is_array($fields['booking']) ? $fields['booking'] : []; foreach ((new DraftBuilder())->systemFieldIds($booking_fields) as $system => $field_id) { if (isset($contact[$system])) $form[$field_id] = $contact[$system]; } $draft->setForm($form); update_post_meta($id, 'daytidy_booking', (string) $draft); $transaction = $booking->getTransaction(); if ($transaction->hasGateway()) { $transaction->setGatewayRefId(sanitize_text_field((string) ($manual['gateway_ref_id'] ?? ''))); update_post_meta($id, 'daytidy_gateway_ref_id', $transaction->getGatewayRefId()); update_post_meta($id, 'daytidy_transaction', (string) $transaction); } $this->syncWooOrderCustomer($id, $first_name, $last_name, $email); if (!empty($manual['resend_created_notification'])) { (new NotificationsDispatcher())->created($id, [ 'source' => 'admin', 'send_notifications' => true, ]); } } private function syncWooOrderCustomer(int $booking_id, string $first_name, string $last_name, string $email): void { $order_id = absint(get_post_meta($booking_id, 'daytidy_woo_order_id', true)); if (!$order_id || !function_exists('wc_get_order')) return; /** @var \WC_Order|false $order */ $order = wc_get_order($order_id); if (!$order) return; $user_id = email_exists($email); $order->set_billing_first_name($first_name); $order->set_billing_last_name($last_name); $order->set_billing_email($email); $order->set_customer_id($user_id ? (int) $user_id : 0); $order->save(); } /** * @param int $post_id * @param mixed $post */ public function postUpdated(int $post_id, $post): void { // This is an auto-save if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return; // It's not a Booking if ($post->post_type !== $this->PT) return; // Modules & Third Parties do_action('DayTidy/A__DayTidy/Posts/Booking__post_updated/updated', $post_id); } /** * @param int $post_id * @param mixed $post * @return void */ public function beforeDelete(int $post_id, $post): void { // It's not a Booking if ($post->post_type !== $this->PT) return; // Modules & Third Parties do_action('DayTidy/A__DayTidy/Posts/Booking__before_delete/before_delete', $post_id); } /** * @param int $post_id * @param mixed $post * @return void */ public function removeScheduled(int $post_id, $post): void { if ($post->post_type === $this->PT && $post->post_status === 'future') wp_publish_post($post_id); } public function entity(int $id): BookingEntity { return new BookingEntity($id); } /** * @return array */ private function bulkStateActions(): array { return (array) apply_filters('DayTidy/F__DayTidy/Posts/Booking__bulkStateActions/actions', [ 'pending' => esc_html__('Pending', 'daytidy'), 'confirm' => esc_html__('Confirm', 'daytidy'), 'decline' => esc_html__('Decline', 'daytidy'), 'cancel' => esc_html__('Cancel', 'daytidy'), ], $this); } /** * @param mixed[] $data * @return mixed[] */ private function cancellationContext(array $data): array { $cancellation = isset($data['cancellation']) && is_array($data['cancellation']) ? $data['cancellation'] : []; return [ 'source' => 'admin', 'actor' => 'admin', 'reason' => sanitize_textarea_field((string) ($cancellation['reason'] ?? '')), ]; } /** * @param mixed[] $data * @param mixed[] $context */ private function storeCancellationReason(int $booking_id, array $data, array $context, StateService $states): void { if (!$this->hasCancellationReason($data)) return; $state = $states->state($booking_id); if ($state[StateService::DIMENSION_BOOKING] !== BookingStatus::CANCELLED) { return; } update_post_meta($booking_id, Cancellation::META_REASON, (string) $context['reason']); } /** * @param mixed[] $data */ private function hasCancellationReason(array $data): bool { return isset($data['cancellation']) && is_array($data['cancellation']) && array_key_exists('reason', $data['cancellation']); } /** * @param mixed[] $data * @return mixed[] */ private function refundArgs(array $data): array { $refund = isset($data['refund']) && is_array($data['refund']) ? $data['refund'] : []; $amount = isset($refund['amount']) ? str_replace(',', '.', sanitize_text_field((string) $refund['amount'])) : 0; return [ 'source' => 'admin', 'charge' => !empty($refund['charge']), 'amount' => is_numeric($amount) ? (float) $amount : 0, ]; } /** * @param mixed[] $result * @param mixed[] $args * @return void */ private function refundNotice(array $result, array $args): void { if (empty($result['success'])) { FlashMessage::error(esc_html($result['message'] ?? __('Gateway refund failed.', 'daytidy'))); return; } if (empty($args['charge'])) return; if (!empty($result['data']['noop'])) { $response = isset($result['data']['response']) && is_array($result['data']['response']) ? $result['data']['response'] : []; FlashMessage::info(esc_html( $response['message'] ?? ($result['message'] ?? __('Gateway charge is already fully refunded.', 'daytidy')) )); return; } FlashMessage::success(esc_html__('Gateway charge refunded successfully.', 'daytidy')); } private function refundChargeProcessed(int $booking_id): bool { return !empty(self::$refundChargeProcessed[$booking_id]); } private function markRefundChargeProcessed(int $booking_id): void { self::$refundChargeProcessed[$booking_id] = true; } }