*/
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 ''
. esc_html($email)
. '';
}
}
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;
}
}