Current File : /home/digitaw/www/wp-content/plugins/event-tickets/src/Tickets/Seating/Commerce/Controller.php
<?php
/**
 * Handles the integration with the Tickets Commerce module.
 *
 * @since 5.16.0
 *
 * @package TEC\Tickets\Seating\Commerce;
 */

namespace TEC\Tickets\Seating\Commerce;

use TEC\Common\Contracts\Provider\Controller as Controller_Contract;
use TEC\Common\lucatume\DI52\Container;
use TEC\Common\StellarWP\DB\DB;
use TEC\Tickets\Commerce\Cart;
use TEC\Tickets\Commerce\Module;
use TEC\Tickets\Commerce\Ticket;
use TEC\Tickets\Seating\Meta;
use TEC\Tickets\Seating\Service\Service;
use TEC\Tickets\Seating\Tables\Seat_Types as Seat_Types_Table;
use Tribe__Cache_Listener as Cache_Listener;
use Tribe__Tickets__Ticket_Object as Ticket_Object;
use Tribe__Tickets__Tickets as Tickets;
use Tribe__Tickets__Tickets_Handler as Tickets_Handler;
use WP_Post;

/**
 * Class Controller.
 *
 * @since 5.16.0
 *
 * @package TEC\Tickets\Seating\Commerce;
 */
class Controller extends Controller_Contract {
	/**
	 * A reference to the Seating Service facade.
	 *
	 * @since 5.16.0
	 *
	 * @var Service
	 */
	private Service $service;

	/**
	 * A reference to the Tickets Handler.
	 *
	 * @since 5.16.0
	 *
	 * @var Tickets_Handler
	 */
	private Tickets_Handler $tickets_handler;
	/**
	 * A reference to the Seat Types Table handle.
	 *
	 * @since 5.16.0
	 *
	 * @var Seat_Types_Table
	 */
	private Seat_Types_Table $seat_types_table;

	/**
	 * A reference to the Attendees handler.
	 *
	 * @since 5.16.0
	 *
	 * @var Attendees
	 */
	private Attendees $attendees;

	/**
	 * Controller constructor.
	 *
	 * @since 5.16.0
	 *
	 * @param Container        $container        A reference to the DI container instance.
	 * @param Service          $service          A reference to the Seating Service facade.
	 * @param Seat_Types_Table $seat_types_table A reference to the Seat Types Table handler.
	 * @param Attendees        $attendees        A reference to the Attendees data handler.
	 */
	public function __construct( Container $container, Service $service, Seat_Types_Table $seat_types_table, Attendees $attendees ) {
		parent::__construct( $container );
		$this->service = $service;
		/** @var Tickets_Handler $tickets_handler */
		$this->tickets_handler  = tribe( 'tickets.handler' );
		$this->seat_types_table = $seat_types_table;
		$this->attendees        = $attendees;
	}

	/**
	 * Subscribes to the WordPress hooks and actions required by the controller.
	 *
	 * @since 5.16.0
	 *
	 * @return void
	 */
	protected function do_register(): void {
		add_filter(
			'tec_tickets_seating_timer_token_object_id_entries',
			[ $this, 'filter_timer_token_object_id_entries' ],
		);
		add_filter( 'tribe_tickets_ticket_inventory', [ $this, 'get_seated_ticket_inventory' ], 10, 3 );
		add_filter( 'tec_tickets_get_ticket_counts', [ $this, 'set_event_stock_counts' ], 10, 2 );
		add_filter( 'update_post_metadata', [ $this, 'prevent_capacity_saves_without_service' ], 1, 4 );
		add_filter( 'update_post_metadata', [ $this, 'handle_ticket_meta_update' ], 10, 4 );
		add_action( 'before_delete_post', [ $this, 'restock_ticket_on_attendee_deletion' ], 10, 2 );
		add_action( 'wp_trash_post', [ $this, 'restock_ticket_on_attendee_trash' ] );
	}

	/**
	 * Unregisters the controller by unsubscribing from WordPress hooks.
	 *
	 * @since 5.16.0
	 *
	 * @return void
	 */
	public function unregister(): void {
		remove_filter(
			'tec_tickets_seating_timer_token_object_id_entries',
			[ $this, 'filter_timer_token_object_id_entries' ],
		);
		remove_filter( 'tribe_tickets_ticket_inventory', [ $this, 'get_seated_ticket_inventory' ] );
		remove_filter( 'tec_tickets_get_ticket_counts', [ $this, 'set_event_stock_counts' ] );
		remove_filter( 'update_post_metadata', [ $this, 'prevent_capacity_saves_without_service' ], 1 );
		remove_filter( 'update_post_metadata', [ $this, 'handle_ticket_meta_update' ], 10 );
		remove_action( 'before_delete_post', [ $this, 'restock_ticket_on_attendee_deletion' ] );
		remove_action( 'wp_trash_post', [ $this, 'restock_ticket_on_attendee_trash' ] );
	}

	/**
	 * Sets the stock counts for the event.
	 *
	 * @since 5.16.0
	 *
	 * @param array<string,array<string|int>> $types   The types of tickets.
	 * @param int                             $post_id The post ID.
	 *
	 * @return array<string,array<string|int>> The types of tickets.
	 */
	public function set_event_stock_counts( $types, $post_id ): array {
		if ( ! tec_tickets_seating_enabled( $post_id ) ) {
			return $types;
		}

		$types['tickets'] = [
			'count'     => 0, // Count of ticket types currently for sale.
			'stock'     => 0, // Current stock of tickets available for sale.
			'global'    => 1, // Numeric boolean if tickets share global stock.
			'unlimited' => 0, // Numeric boolean if any ticket has unlimited stock.
			'available' => 0,
		];

		$tickets = tribe_tickets()
			->where( 'event', $post_id )
			->get_ids( true );

		$capacity_by_type   = [];
		$total_sold_by_type = [];

		foreach ( $tickets as $ticket_id ) {
			$ticket = Tickets::load_ticket_object( $ticket_id );

			if ( ! $ticket instanceof Ticket_Object ) {
				continue;
			}

			if ( ! tribe_events_ticket_is_on_sale( $ticket ) ) {
				continue;
			}

			$seat_type = get_post_meta( $ticket_id, META::META_KEY_SEAT_TYPE, true );

			if ( empty( $seat_type ) ) {
				continue;
			}

			$capacity = $ticket->capacity();
			$stock    = $ticket->stock();
			$sold_qty = $ticket->qty_sold();

			if ( ! isset( $capacity_by_type[ $seat_type ] ) ) {
				$capacity_by_type[ $seat_type ] = $capacity;
			}

			if ( ! isset( $total_sold_by_type[ $seat_type ] ) ) {
				$total_sold_by_type[ $seat_type ] = $sold_qty;
			} else {
				$total_sold_by_type[ $seat_type ] += $sold_qty;
			}

			++$types['tickets']['count'];
		}

		foreach ( $capacity_by_type as $seat_type => $capacity ) {
			$stock_level                    = $capacity - $total_sold_by_type[ $seat_type ];
			$types['tickets']['stock']     += $stock_level;
			$types['tickets']['available'] += $stock_level;
		}

		return $types;
	}

	/**
	 * Adjusts the seated ticket inventory to match the stock.
	 *
	 * @since 5.16.0
	 *
	 * @param int                        $inventory       The current inventory.
	 * @param Ticket_Object              $ticket          The ticket object.
	 * @param array<array<string,mixed>> $event_attendees The post Attendees.
	 *
	 * @return int The adjusted inventory.
	 */
	public function get_seated_ticket_inventory( int $inventory, Ticket_Object $ticket, array $event_attendees ): int {
		$seat_type = get_post_meta( $ticket->ID, Meta::META_KEY_SEAT_TYPE, true );

		if ( ! $seat_type ) {
			return $inventory;
		}

		$event_id = $ticket->get_event_id();
		$capacity = $ticket->capacity();

		// Remove this function from the filter to avoid infinite loops.
		remove_filter( 'tribe_tickets_ticket_inventory', [ $this, 'get_seated_ticket_inventory' ] );

		// Later we'll remove this specific return false filter, not one that might have been added by other code.
		$return_false = static fn() => false;
		add_filter( 'tribe_tickets_ticket_object_is_ticket_cache_enabled', $return_false );

		$ticket_ids = [ $ticket->ID ];

		// Pull the inventory from the other tickets with the same seat type.
		foreach (
			tribe_tickets()
				->where( 'event', $event_id )
				->not_in( $ticket->ID )
				->where( 'meta_equals', Meta::META_KEY_SEAT_TYPE, $seat_type )
				->get_ids( true ) as $ticket_id
		) {
			$ticket_ids[] = (int) $ticket_id;
		}

		$total_sold = 0;
		if ( count( $event_attendees ) ) {
			$total_sold = count(
				array_filter(
					$event_attendees,
					static fn( array $attendee ): bool => in_array( (int) $attendee['product_id'], $ticket_ids, true )
				)
			);
		}

		add_filter(
			'tribe_tickets_ticket_inventory',
			[ $this, 'get_seated_ticket_inventory' ],
			10,
			3
		);
		remove_filter( 'tribe_tickets_ticket_object_is_ticket_cache_enabled', $return_false );

		return $capacity - $total_sold;
	}

	/**
	 * Filters the handler used to get the token and object ID from the cookie.
	 *
	 * @since 5.16.0
	 *
	 * @param array<string,string> $session_entries The entries from the cookie. A map from object ID to token.
	 *
	 * @return array<string,string> The entries from the cookie. A map from object ID to token.
	 */
	public function filter_timer_token_object_id_entries( $session_entries ): array {
		$tickets_commerce = tribe( Module::class );

		if ( empty( $session_entries ) || ! $tickets_commerce->is_checkout_page() ) {
			// Not a Tickets Commerce checkout page: return the original entries.
			return $session_entries;
		}

		// Get the post IDs in the cart.
		global $wpdb;
		/** @var Cart $cart */
		$cart       = tribe( Cart::class );
		$cart_items = array_keys( $cart->get_items_in_cart() );

		if ( empty( $cart_items ) ) {
			return [];
		}

		$ticket_ids_interval = DB::prepare(
			implode( ',', array_fill( 0, count( $cart_items ), '%d' ) ),
			...$cart_items
		);
		$cart_post_ids       = DB::get_col(
			DB::prepare(
				"SELECT DISTINCT( meta_value ) FROM %i WHERE post_id IN ({$ticket_ids_interval}) AND meta_key = %s ",
				$wpdb->postmeta,
				Module::ATTENDEE_EVENT_KEY
			)
		);

		// Get the post IDs in the session.
		$session_post_ids = array_keys( $session_entries );

		// Find out the post IDs part of both the cart and the seat selection session.
		$cart_and_session_ids = array_intersect( $cart_post_ids, $session_post_ids );

		if ( empty( $cart_and_session_ids ) ) {
			// There are no Tickets for posts using Seat Assignment in the cart.
			return [];
		}

		return array_combine(
			$cart_and_session_ids,
			array_map(
				static function ( $item ) use ( $session_entries ) {
					return $session_entries[ $item ];
				},
				$cart_and_session_ids
			)
		);
	}

	/**
	 * Cross-updates the Ticket stock meta across a set of Tickets sharing the same seat type and post.
	 *
	 * @since 5.16.0
	 *
	 * @param int    $ticket_id      The Ticket ID to start the cross-update from.
	 * @param string $seat_type      The seat type UUID.
	 * @param int    $stock_modifier Modify the stock further by this amount. Useful when we already know the value
	 *                               will be modified by a certain amount in the context of this call (e.g. when handling
	 *                               an Attendee deletion).
	 *
	 * @return bool Whether the meta update of the Ticket specified by `$ticket_id` was successful or not.
	 */
	private function update_seated_ticket_stock( int $ticket_id, string $seat_type, int $stock_modifier = 0 ): bool {
		$ticket = Tickets::load_ticket_object( $ticket_id );

		if ( ! $ticket instanceof Ticket_Object ) {
			return false;
		}

		$event = $ticket->get_event();

		if ( ! $event instanceof WP_Post || ! $event->ID ) {
			return false;
		}

		$seat_type_seats           = $this->seat_types_table->get_seats( $seat_type );
		$seat_type_attendees_count = $this->attendees->get_count_by_post_seat_type( $event->ID, $seat_type );
		$updated_stock             = $seat_type_seats - $seat_type_attendees_count + $stock_modifier;

		$updated = update_post_meta( $ticket_id, Ticket::$stock_meta_key, $updated_stock );

		// Trigger the save post cache invalidation for this ticket.
		$cache_listener    = tribe( Cache_Listener::class );
		$to_invalidate_ids = [ $ticket_id ];

		/*
		 * Not memoized as its invalidation could not be handled only in this Controller and would run the risk of
		 * caching the wrong value.
		 */
		foreach (
			tribe_tickets()
				->where( 'event', $event->ID )
				->not_in( $ticket->ID )
				->where( 'meta_equals', Meta::META_KEY_SEAT_TYPE, $seat_type )
				->get_ids( true ) as $seat_type_ticket_id
		) {
			update_post_meta( $seat_type_ticket_id, Ticket::$stock_meta_key, $updated_stock );
			$to_invalidate_ids[] = $seat_type_ticket_id;
		}

		// This cross-update might have skipped some methods that would normally invalidate theirs caches: do it now.
		foreach ( $to_invalidate_ids as $to_invalidate_id ) {
			// Trigger the save post cache invalidation for this ticket.
			$cache_listener->save_post( $to_invalidate_id, get_post( $to_invalidate_id ) );
		}

		return $updated;
	}

	/**
	 * Prevents the update of the capacity meta keys for Tickets that are ASC tickets and Ticket-able Post types that are using Seating.
	 *
	 * @since 5.16.0
	 *
	 * @param null|bool $check      Whether to allow the update (`null`) or whether the update is already being processed.
	 * @param int       $object_id  The ID of the object being updated.
	 * @param string    $meta_key   The meta key being updated.
	 * @param mixed     $meta_value The new value for the meta key.
	 *
	 * @return null|bool Whether to allow the update (`null`) or whether the update is already being processed and
	 *                   what is the update result (`false|true`).
	 */
	public function prevent_capacity_saves_without_service( $check, $object_id, $meta_key, $meta_value ) {
		if ( $check !== null ) {
			// Some other code is already controlling the update, so we should not.
			return $check;
		}

		if ( ! in_array(
			$meta_key,
			[
				Ticket::$stock_meta_key,
				$this->tickets_handler->key_capacity,
			],
			true
		) ) {
			// Not a ticket meta key we care about.
			return $check;
		}

		$ticket_post_types      = tribe_tickets()->ticket_types();
		$ticket_able_post_types = (array) tribe_get_option( 'ticket-enabled-post-types', [] );

		if ( ! in_array( get_post_type( $object_id ), $ticket_post_types, true ) && ! in_array( get_post_type( $object_id ), $ticket_able_post_types, true ) ) {
			// Not a ticket post type.
			return $check;
		}

		$seat_type = get_post_meta( $object_id, Meta::META_KEY_SEAT_TYPE, true );

		if ( ! $seat_type && ! tec_tickets_seating_enabled( $object_id ) ) {
			// Not an ASC ticket.
			return $check;
		}

		if ( ! $this->service->get_status()->is_ok() ) {
			// Service status is not OK: prevent the update until the service comes back online.
			return false;
		}

		return $check;
	}

	/**
	 * Handle the update of some ticket meta keys depending on the service status and taking care to update
	 * related meta in other Tickets that should be affected.
	 *
	 * @since 5.16.0
	 *
	 * @param null|bool $check      Whether to allow the update (`null`) or whether the update is already being processed.
	 * @param int       $object_id  The ID of the object being updated.
	 * @param string    $meta_key   The meta key being updated.
	 * @param mixed     $meta_value The new value for the meta key.
	 *
	 * @return null|bool Whether to allow the update (`null`) or whether the update is already being processed and
	 *                   what is the update result (`false|true`).
	 */
	public function handle_ticket_meta_update( $check, $object_id, $meta_key, $meta_value ) {
		if ( $check !== null ) {
			// Some other code is already controlling the update, so we should not.
			return $check;
		}

		if ( ! in_array(
			$meta_key,
			[
				Ticket::$stock_meta_key,
				$this->tickets_handler->key_capacity,
			],
			true
		) ) {
			// Not a ticket meta key we care about.
			return $check;
		}

		$ticket_post_types = tribe_tickets()->ticket_types();

		if ( ! in_array( get_post_type( $object_id ), $ticket_post_types, true ) ) {
			// Not a ticket post type.
			return $check;
		}

		$seat_type = get_post_meta( $object_id, Meta::META_KEY_SEAT_TYPE, true );

		if ( ! $seat_type ) {
			// Not an ASC ticket.
			return $check;
		}

		// Remove this filter to avoid infinite loops.
		remove_filter( 'update_post_metadata', [ $this, 'handle_ticket_meta_update' ] );

		if ( $meta_key === Ticket::$stock_meta_key ) {
			// Meta value might be negative from default calculation: not an issue, we run a different calculation.
			$updated = $this->update_seated_ticket_stock( $object_id, $seat_type );
		} else {
			if ( (int) $meta_value < 0 ) {
				// Not syncing unlimited capacity: no such thing as infinite seats.
				return false;
			}
			$updated = update_post_meta( $object_id, $meta_key, $meta_value );
		}

		add_filter( 'update_post_metadata', [ $this, 'handle_ticket_meta_update' ], 10, 4 );

		return $updated;
	}

	/**
	 * Updates the stock of the Tickets sharing the same seat type when an Attendee is trashed.
	 *
	 * @since 5.16.0
	 *
	 * @param int $post_id The ID of the post being trashed.
	 *
	 * @return void
	 */
	public function restock_ticket_on_attendee_trash( $post_id ) {
		$post = get_post( $post_id );

		$attendee_types = tribe_attendees()->attendee_types();
		if ( ! $post instanceof WP_Post && in_array( $post->post_type, $attendee_types, true ) ) {
			return;
		}

		$this->restock_ticket_on_attendee_deletion( $post_id, $post );
	}

	/**
	 * Updates the stock of the Tickets sharing the same seat type when an Attendee is deleted.
	 *
	 * @since 5.16.0
	 *
	 * @param int     $post_id The ID of the post being deleted.
	 * @param WP_Post $post    The post object being deleted.
	 *
	 * @return void
	 */
	public function restock_ticket_on_attendee_deletion( $post_id, $post ) {
		if ( ! ( $post instanceof WP_Post ) ) {
			return;
		}

		$attendee_types = tribe_attendees()->attendee_types();

		if ( ! in_array( $post->post_type, $attendee_types, true ) ) {
			return;
		}

		// Fetching Post and Ticket ID from the Attendee can require some queries, but the data is already in the meta (cached).
		$attendee_to_post_keys   = array_values( tribe_attendees()->attendee_to_event_keys() );
		$attendee_to_ticket_keys = array_values( tribe_attendees()->attendee_to_ticket_keys() );
		$attendee_meta           = get_post_meta( $post_id );
		$post_id                 = null;
		$ticket_id               = null;
		foreach ( $attendee_meta as $meta_key => $meta_value ) {
			if ( $post_id && $ticket_id ) {
				break;
			}

			if ( in_array( $meta_key, $attendee_to_post_keys, true ) ) {
				$post_id = reset( $meta_value );
				continue;
			}

			if ( in_array( $meta_key, $attendee_to_ticket_keys, true ) ) {
				$ticket_id = reset( $meta_value );
			}
		}

		if ( ! ( $post_id && $ticket_id ) ) {
			return;
		}

		$seat_type = get_post_meta( $ticket_id, Meta::META_KEY_SEAT_TYPE, true );

		if ( ! $seat_type ) {
			return;
		}

		remove_filter( 'update_post_metadata', [ $this, 'handle_ticket_meta_update' ] );

		// Updating this Ticket will update all the Tickets that share the same seat type.
		$this->update_seated_ticket_stock( $ticket_id, $seat_type, 1 );

		add_filter( 'update_post_metadata', [ $this, 'handle_ticket_meta_update' ], 10, 4 );
	}
}