# booktics/trunk/core/module/google-calendar/Service/calendar.php

Booktics – Appointment Booking Calendar for Service Businesses, version trunk. 664 lines.

- Page: https://pluginprobe.com/plugins/booktics/trunk/code/core/module/google-calendar/Service/calendar.php
- Raw: https://pluginprobe.com/plugins/booktics/trunk/raw/core/module/google-calendar/Service/calendar.php
- Modified: 2026-09-09T04:03:56+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/booktics/trunk/code/core/module/google-calendar/Service/calendar.php#L10-L20`.

```php
<?php

/**
 * Google Calendar Service with Token Refresh
 *
 * @package     BookticsGoogleCalendar
 * @subpackage  Google\Service
 * @since       1.0.0
 */

namespace Booktics\Module\Google_Calendar\Service;

use Booktics\Abstracts\Booktics_Database;
use Booktics\Calendar\Calendar_Interface;
use Booktics\Models\Customer_Model;
use Booktics\Models\Service_Model;
use Exception;
use WP_Error;

// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;

/**
 * Class Calendar
 *
 * Handles Google Calendar integration and operations using direct API calls.
 *
 * @since 1.0.0
 */
class Calendar implements Calendar_Interface {

	/**
	 * Google API configuration.
	 *
	 * @since 1.0.0
	 * @var array
	 */
	protected $config;

	/**
	 * Default calendar ID.
	 *
	 * @since 1.0.0
	 * @var string
	 */
	private $calendar_id = 'primary';

	const BOOKTICS_GOOGLE_CALENDAR_EVENT = 'https://www.googleapis.com/calendar/v3/calendars/primary/events';

	/**
	 * Constructor with API configuration.
	 *
	 * @since 1.0.0
	 */
	public function __construct() {
		$this->config = array(
			'client_id'     => booktics_get_option( 'google_client_id' ),
			'client_secret' => booktics_get_option( 'google_secret_key' ),
			'base_url'      => 'https://www.googleapis.com/calendar/v3',
		);
	}

	/**
	 * Get valid access token for API calls with automatic refresh.
	 *
	 * @param int $user_id User ID
	 * @return string|false Access token or false on failure
	 */
	private function get_valid_access_token( $user_id ) {
		$tokens = get_user_meta( $user_id, 'booktics_google_auth_token', true );

		if ( empty( $tokens ) ) {
			return false;
		}

		// Handle both string (old format) and array (new format) tokens
		if ( is_string( $tokens ) ) {
			$tokens = json_decode( $tokens, true );
		}

		if ( ! is_array( $tokens ) || empty( $tokens['access_token'] ) ) {
			return false;
		}

		// Check if token needs refreshing
		if ( $this->is_token_expired( $tokens ) ) {
			$refreshed_tokens = $this->refresh_access_token( $tokens, $user_id );
			if ( ! $refreshed_tokens ) {
				return false;
			}
			return $refreshed_tokens['access_token'];
		}

		return $tokens['access_token'];
	}

	/**
	 * Check if token is expired
	 *
	 * @param array $tokens Token array
	 * @return bool True if expired
	 */
	private function is_token_expired( $tokens ) {
		if ( empty( $tokens['expires_at'] ) ) {
			return true;
		}

		$expires_at = is_numeric( $tokens['expires_at'] ) ? $tokens['expires_at'] : strtotime( $tokens['expires_at'] );
		$buffer_time = 300; // 5 minutes buffer

		return ( time() + $buffer_time ) >= $expires_at;
	}

	/**
	 * Refresh access token using refresh token
	 *
	 * @param array $tokens Current tokens
	 * @param int   $user_id User ID
	 * @return array|false Refreshed tokens or false on failure
	 */
	private function refresh_access_token( $tokens, $user_id ) {
		if ( empty( $tokens['refresh_token'] ) ) {
			return false;
		}

		$response = wp_remote_post(
			'https://oauth2.googleapis.com/token',
			array(
				'body' => array(
					'client_id'     => $this->config['client_id'],
					'client_secret' => $this->config['client_secret'],
					'refresh_token' => $tokens['refresh_token'],
					'grant_type'    => 'refresh_token',
				),
			)
		);

		if ( is_wp_error( $response ) ) {
			return false;
		}

		$body = wp_remote_retrieve_body( $response );
		$new_token = json_decode( $body, true );

		if ( isset( $new_token['error'] ) ) {
			// `invalid_grant` means the refresh token was revoked or expired (e.g., 7-day limit in "Testing" mode).
            // Clear it so status reports correctly and prompts reconnection. Other errors are transient.
			if ( 'invalid_grant' === $new_token['error'] ) {
				delete_user_meta( $user_id, 'booktics_google_auth_token' );
			}

			if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
				error_log( '[Booktics Google Calendar] Token refresh failed for user ' . $user_id . ': ' . $new_token['error'] ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			}

			return false;
		}

		// Merge with existing tokens to preserve refresh_token
		$refreshed_tokens = array_merge( $tokens, $new_token );

		// Set expiry time
		if ( isset( $refreshed_tokens['expires_in'] ) ) {
			$refreshed_tokens['expires_at'] = time() + $refreshed_tokens['expires_in'];
		}

		// Save the refreshed tokens
		update_user_meta( $user_id, 'booktics_google_auth_token', $refreshed_tokens );

		return $refreshed_tokens;
	}

	/**
	 * Get access token for API calls with automatic refresh.
	 *
	 * @param int $user_id User ID
	 * @return string|false Access token or false on failure
	 */
	public function get_access_token_for_api( $user_id = null ) {
		if ( ! $user_id ) {
			$user_id = get_current_user_id();
		}

		return $this->get_valid_access_token( $user_id );
	}

	/**
	 * Create event using direct Google Calendar API calls.
	 *
	 * @param \Booktics\Models\AppointmentModel $appointment appointment data
	 * @param int                               $user_id user id
	 * @param array                             $args additional arguments
	 * @return string|false Event ID on success, false on failure
	 */
	public function create_event($appointment, $user_id = null, $args = array()) {
		if (!$user_id) {
			$user_id = get_current_user_id();
		}

		$access_token = $this->get_access_token_for_api($user_id);
		
		if ( !$access_token ) {
			return false;
		}

		// Handle customer data safely
		$customer_email = '';
		if (!empty($appointment->customer_id) && $appointment->customer_id > 0) {
			try {
				$customer = new Customer_Model($appointment->customer_id);
				$customer_email = $customer->user_email ?? '';
			} catch (Exception $e) {
				if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
					error_log('[Booktics Google Calendar] Failed to load customer data: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
				}
			}
		}

		$service = new Service_Model( $appointment->service_id );

		// Combine date with start and end times
		$start_datetime_string = $appointment->date . ' ' . $appointment->start_time;
		$end_datetime_string = $appointment->date . ' ' . $appointment->end_time;

		// Get timezone from the appointment times (fallback to WordPress timezone)
		$appointment_timezone = $this->get_appointment_timezone( $appointment );

		// Create DateTime objects with proper timezone handling
		try {
			$timezone_obj = new \DateTimeZone($appointment_timezone);

			// Convert 12-hour format to 24-hour format if needed
			$start_time_24h = gmdate('H:i', strtotime($appointment->start_time));
			$end_time_24h = gmdate('H:i', strtotime($appointment->end_time));

			$start_datetime_24h = $appointment->date . ' ' . $start_time_24h;
			$end_datetime_24h = $appointment->date . ' ' . $end_time_24h;

			$start_dt = new \DateTime($start_datetime_24h, $timezone_obj);
			$end_dt = new \DateTime($end_datetime_24h, $timezone_obj);

			$start_datetime_formatted = $start_dt->format('c'); // RFC3339 format
			$end_datetime_formatted = $end_dt->format('c'); // RFC3339 format

		} catch (\Exception $e) {
			// Fallback to original method if timezone creation fails
			if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
				error_log('[Booktics Google Calendar] Timezone error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			}
			$start_datetime_formatted = wp_date('Y-m-d\TH:i:s', strtotime( $start_datetime_string ));
			$end_datetime_formatted = wp_date('Y-m-d\TH:i:s', strtotime( $end_datetime_string ));
		}

		$defaults = array(
			'summary'     => $service->title ?? 'Booktics Appointment',
			'description' => $appointment->note ?? '',
			'location'    => $appointment->location ?? '',
			'start'       => array(
				'dateTime' => $start_datetime_formatted,
				'timeZone' => $appointment_timezone,
			),
			'end'         => array(
				'dateTime' => $end_datetime_formatted,
				'timeZone' => $appointment_timezone,
			),
			'attendees' => !empty($customer_email) ? array(
				array('email' => $customer_email),
			) : array(),
			'google_meet' => false,
		);

		$args = wp_parse_args($args, $defaults);

		$data = array(
			'headers' => array(
				'Authorization' => 'Bearer ' . $access_token,
				'Content-Type'  => 'application/json; charset=utf-8',
			),
			'body'    => wp_json_encode($args),
		);
		$query_params = build_query(
			array(
				'conferenceDataVersion' => '1',
			)
		);

		$response = wp_remote_post(self::BOOKTICS_GOOGLE_CALENDAR_EVENT . '?' . $query_params, $data);

		if ( is_wp_error( $response ) ) {
			return false;
		}

		$status_code = wp_remote_retrieve_response_code( $response );

		if (200 != $status_code && 201 != $status_code) {
			return false;
		}

		$response_data = json_decode(wp_remote_retrieve_body($response), true);
		return $response_data['id'] ?? false;
	}

	/**
	 * Update event using direct Google Calendar API calls.
	 *
	 * @param string                            $event_id Event ID
	 * @param \Booktics\Models\AppointmentModel $appointment Appointment data
	 * @param int                               $user_id user id
	 * @param array                             $args additional arguments
	 * @return bool True on success, false on failure
	 */
	public function update_event(string $event_id, $appointment, $user_id = null, $args = array()): bool {
		if (!$user_id) {
			$user_id = get_current_user_id();
		}

		$access_token = $this->get_access_token_for_api($user_id);
		
		if (!$access_token) {
			return false;
		}

		// Handle customer data safely
		$customer_email = '';
		if (!empty($appointment->customer_id) && $appointment->customer_id > 0) {
			try {
				$customer = new Customer_Model($appointment->customer_id);
				$customer_email = $customer->user_email ?? '';
			} catch (Exception $e) {
				if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
					error_log('[Booktics Google Calendar] Failed to load customer data: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
				}
			}
		}

		$service = new Service_Model($appointment->service_id);

		// Combine date with start and end times
		$start_datetime_string = $appointment->date . ' ' . $appointment->start_time;
		$end_datetime_string = $appointment->date . ' ' . $appointment->end_time;

		// Get timezone from the appointment times (fallback to WordPress timezone)
		$appointment_timezone = $this->get_appointment_timezone( $appointment );

		// Create DateTime objects with proper timezone handling
		try {
			$timezone_obj = new \DateTimeZone($appointment_timezone);

			// Convert 12-hour format to 24-hour format if needed
			$start_time_24h = gmdate('H:i', strtotime($appointment->start_time));
			$end_time_24h = gmdate('H:i', strtotime($appointment->end_time));

			$start_datetime_24h = $appointment->date . ' ' . $start_time_24h;
			$end_datetime_24h = $appointment->date . ' ' . $end_time_24h;

			$start_dt = new \DateTime($start_datetime_24h, $timezone_obj);
			$end_dt = new \DateTime($end_datetime_24h, $timezone_obj);

			$start_datetime_formatted = $start_dt->format('c'); // RFC3339 format
			$end_datetime_formatted = $end_dt->format('c'); // RFC3339 format

		} catch (\Exception $e) {
			// Fallback to original method if timezone creation fails
			if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
				error_log('[Booktics Google Calendar] Timezone error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			}
			$start_datetime_formatted = wp_date('Y-m-d\TH:i:s', strtotime($start_datetime_string));
			$end_datetime_formatted = wp_date('Y-m-d\TH:i:s', strtotime($end_datetime_string));
		}

		$defaults = array(
			'summary'     => $service->title ?? 'Booktics Appointment',
			'description' => $appointment->note ?? '',
			'location'    => $appointment->location ?? '',
			'start'       => array(
				'dateTime' => $start_datetime_formatted,
				'timeZone' => $appointment_timezone,
			),
			'end'         => array(
				'dateTime' => $end_datetime_formatted,
				'timeZone' => $appointment_timezone,
			),
			'attendees' => !empty($customer_email) ? array(
				array('email' => $customer_email),
			) : array(),
			'google_meet' => false,
		);

		$args = wp_parse_args($args, $defaults);

		$data = array(
			'headers' => array(
				'Authorization' => 'Bearer ' . $access_token,
				'Content-Type'  => 'application/json; charset=utf-8',
			),
			'body'    => wp_json_encode($args),
			'method'  => 'PUT',
		);

		$query_params = build_query(
			array(
				'conferenceDataVersion' => '1',
				'sendUpdates'           => 'all',
			)
		);

		$response = wp_remote_post(self::BOOKTICS_GOOGLE_CALENDAR_EVENT . '/' . $event_id . '?' . $query_params, $data);

		if (is_wp_error($response)) {
			return false;
		}

		$status_code = wp_remote_retrieve_response_code($response);

		if (200 != $status_code) {
			return false;
		}

		$response_data = json_decode(wp_remote_retrieve_body($response), true);

		return true;
	}

	/**
	 * Delete event using direct Google Calendar API calls.
	 *
	 * @param string $event_id Event ID
	 * @param int    $user_id user id
	 * @return bool True on success, false on failure
	 */
	public function delete_event(string $event_id, $user_id = null): bool {
		if (!$user_id) {
			$user_id = get_current_user_id();
		}

		$access_token = $this->get_access_token_for_api($user_id);

		if (!$access_token) {
			return false;
		}

		$data = array(
			'headers' => array(
				'Authorization' => 'Bearer ' . $access_token,
				'Content-Type'  => 'application/json; charset=utf-8',
			),
			'method'  => 'DELETE',
		);

		$query_params = build_query(
			array(
				'conferenceDataVersion' => '1',
				'sendUpdates'           => 'all',
			)
		);

		$response = wp_remote_post(self::BOOKTICS_GOOGLE_CALENDAR_EVENT . '/' . $event_id . '?' . $query_params, $data);

		if (is_wp_error($response)) {
			return false;
		}

		$status_code = wp_remote_retrieve_response_code($response);

		if (204 != $status_code && 200 != $status_code) {
			return false;
		}

		return true;
	}

	/**
	 * Get Google Calendar events using direct API calls.
	 *
	 * @param int   $user_id Team member user ID
	 * @param array $api_filters Additional API filters
	 * @return array|WP_Error Array of events or WP_Error on failure
	 */
	public function get_google_calendar_events($user_id = null, $api_filters = array()) {
		if (!$user_id) {
			$user_id = get_current_user_id();
		}

		$access_token = $this->get_access_token_for_api($user_id);
		
		if (!$access_token) {
			return new WP_Error('invalid_tokens', __('Failed to get valid access token.', 'booktics'));
		}

		// Define the time range
		$three_months_ago = wp_date('c', strtotime('-3 months'));
		$three_months_ahead = wp_date('c', strtotime('+3 months'));

		$filters = array(
			'timeMin'      => rawurlencode($three_months_ago),
			'timeMax'      => rawurlencode($three_months_ahead),
			'orderBy'      => 'startTime',
			'singleEvents' => 'true',
		);

		$filters = array_merge($filters, $api_filters);
		$api_url = add_query_arg($filters, self::BOOKTICS_GOOGLE_CALENDAR_EVENT);

		$args = array(
			'headers' => array(
				'Authorization' => 'Bearer ' . $access_token,
				'Content-Type'  => 'application/json',
			),
		);

		$response = wp_remote_get($api_url, $args);
		
		if (is_wp_error($response)) {
			return array();
		}

		$body = wp_remote_retrieve_body($response);
		$events = json_decode($body, true);

		if (empty($events['items'])) {
			return array();
		}

		return $events['items'];
	}

	/**
	 * Prepare appointment data from Google Calendar event.
	 *
	 * @param array $event Event data from Google Calendar API
	 * @return array
	 */
	public function prepare_appointment_data_from_event( $event ) {
		try {
			$start = $event['start'];
			$end   = $event['end'];

			// Parse date and time
			$start_datetime = new \DateTime( $start['dateTime'] );
			$end_datetime   = new \DateTime( $end['dateTime'] );

			// Extract service and customer names from summary
			$summary      = $event['summary'];
			$parts        = explode( ' - ', $summary, 2 );
			$service_name = $parts[0] ?? '';
			$event_name   = $parts[1] ?? $summary;

			// Build appointment data
			$appointment_data = array(
				'title'      => $event_name,
				'content'    => $event['summary'],
				'status'     => 'approved',
				'type'       => 'booktics-appointment',
				'source'     => 'google',
				'email'      => '',
				'date'       => $start_datetime->format( 'Y-m-d' ),
				'start_time' => $start_datetime->format( 'g:i A' ),
				'end_time'   => $end_datetime->format( 'g:i A' ),
				'customer'   => array(
					'display_name' => $event_name,
					'note'         => '',
				),
				'service'    => array(
					'title' => $service_name,
				),
			);

			return $appointment_data;
		} catch ( Exception $e ) {
			throw $e;
		}
	}

	/**
	 * Check if calendar is configured for a team member.
	 *
	 * @since 1.0.0
	 *
	 * @param int $team_member_id Team member user ID.
	 * @return bool True if configured, false otherwise.
	 */
	public function is_configured( int $team_member_id ): bool {
		$tokens = get_user_meta( $team_member_id, 'booktics_google_auth_token', true );
		return ! empty( $tokens );
	}
	/**
	 * Returns the id for the calendar.
	 * 
	 * @return string
	 */
	public static function get_id(): string {
		return 'google_calendar';
	}

	/**
	 * Implemented to ensure woring code for extending the calendar interface
	 */
	public function sync_events(int $team_member_id) {
		// intentionally kept empty to implement calendar interface;
	}

	/**
	 * Selects the timezone for the appointment. Including legacy appointments which doesnt have timezone.
	 * 
	 * @return string timezone string.
	 */
	private function get_appointment_timezone($appointment) {
		// Try different ways to access the timezone
		$timezone_sources = [
			'direct_property' => $appointment->timezone ?? null,
			'to_array' => method_exists($appointment, 'to_array') ? ($appointment->to_array()['timezone'] ?? null) : null,
		];

		// Check each source
		foreach ($timezone_sources as $source => $value) {
			if (!empty($value) && $value !== '+00:00') {
				return $value;
			}
		}

		// Try to get from post meta
		if (!empty($appointment->id)) {
			$meta_timezone = get_post_meta($appointment->id, 'timezone', true);
			if (!empty($meta_timezone) && $meta_timezone !== '+00:00') {
				return $meta_timezone;
			}
		}

		if (!empty($appointment->order_id)) {
			$db = new Booktics_Database();
			$order = new \Booktics\Models\Order_Model( $db );
			$order_data = $order->find(['id' => $appointment->order_id]);

			if ($order_data && $order_data->timezone) {
				$order_timezone = $order_data->timezone instanceof \DateTimeZone
					? $order_data->timezone->getName()
					: $order_data->timezone;

				if (!empty($order_timezone) && $order_timezone !== '+00:00') {
					return $order_timezone;
				}
			}
		}

		// Fallback to WordPress timezone
		return wp_timezone_string();
	}

	/**
	 * Register the service.
	 *
	 * @since 1.0.0
	 */
	public function register() {
		// Register this calendar with the registry.
		add_action(
			'booktics_calendars_register',
			function () {
				\Booktics\Calendar\Calendar_Registry::register_calendar( static::class );
			}
		);
	}
}

```
