Current File : /home/digitaw/www/wp-content/plugins/event-tickets/src/Tickets/Commerce/Stock_Validator.php
<?php
/**
 * Stock Validator with Database Locking.
 *
 * Provides atomic stock validation using database row-level locking
 * to prevent overselling during concurrent purchases.
 *
 * @since 5.26.7
 *
 * @package TEC\Tickets\Commerce
 */

namespace TEC\Tickets\Commerce;

use TEC\Common\StellarWP\DB\DB;
use TEC\Common\StellarWP\DB\Database\Exceptions\DatabaseQueryException;
use TEC\Tickets\Commerce\Status\Status_Handler;
use TEC\Tickets\Commerce\Traits\Is_Ticket;
use Tribe__Utils__Array as Arr;
use Tribe__Tickets__Tickets;
use WP_Error;
use WP_Post;

/**
 * Class Stock_Validator.
 *
 * @since 5.26.7
 *
 * @package TEC\Tickets\Commerce
 */
class Stock_Validator {

	use Is_Ticket;

	/**
	 * Validates cart stock with database row-level locking.
	 *
	 * This method acquires row-level locks on ticket stock meta rows
	 * using SELECT ... FOR UPDATE to prevent concurrent modifications
	 * during validation.
	 *
	 * @since 5.26.7
	 *
	 * @param Cart $cart The cart to validate.
	 *
	 * @return true|WP_Error True if stock is available, WP_Error if not.
	 */
	public function validate_cart_stock_with_lock( Cart $cart ) {
		$items = $cart->get_items_in_cart();

		// Bail early if cart is empty.
		if ( empty( $items ) ) {
			return true;
		}

		$validation_errors = [];

		foreach ( $items as $item ) {
			// Skip non-ticket items.
			if ( ! $this->is_ticket( $item ) ) {
				continue;
			}

			$ticket_id = Arr::get( $item, 'ticket_id' );
			$quantity  = (int) Arr::get( $item, 'quantity', 1 );

			// Skip if the requested quantity is invalid (user is trying to purchase 0 or negative tickets).
			// This is an edge case for malformed cart data and should not happen in normal operation.
			if ( $quantity <= 0 ) {
				continue;
			}

			// Lock and validate this ticket's stock.
			$validation_result = $this->validate_ticket_stock_with_lock( $ticket_id, $quantity );

			// Collect any validation errors.
			if ( is_wp_error( $validation_result ) ) {
				$validation_errors[] = $validation_result->get_error_data();
			}
		}

		// Bail early if we have validation errors.
		if ( ! empty( $validation_errors ) ) {
			return $this->build_insufficient_stock_error( $validation_errors );
		}

		return true;
	}

	/**
	 * Validates order stock for a specific order.
	 *
	 * Used during order status transitions to provide secondary validation.
	 *
	 * @since 5.26.7
	 *
	 * @param WP_Post $order The order to validate.
	 *
	 * @return true|WP_Error True if stock is available, WP_Error if not.
	 */
	public function validate_order_stock( WP_Post $order ) {
		// Bail early if order has no items.
		if ( empty( $order->items ) || ! is_array( $order->items ) ) {
			return true;
		}

		$validation_errors = [];

		foreach ( $order->items as $item ) {
			// Skip non-ticket items.
			if ( ! $this->is_ticket( $item ) ) {
				continue;
			}

			$ticket_id = Arr::get( $item, 'ticket_id' );
			$quantity  = (int) Arr::get( $item, 'quantity', 1 );

			// Skip if the requested quantity is invalid (user is trying to purchase 0 or negative tickets).
			if ( $quantity <= 0 ) {
				continue;
			}

			// Validate this ticket's stock (already in transaction, so uses existing locks).
			$validation_result = $this->validate_ticket_stock( $ticket_id, $quantity );

			// Collect any validation errors.
			if ( is_wp_error( $validation_result ) ) {
				$validation_errors[] = $validation_result->get_error_data();
			}
		}

		// Bail early if we have validation errors.
		if ( ! empty( $validation_errors ) ) {
			return $this->build_insufficient_stock_error( $validation_errors );
		}

		return true;
	}

	/**
	 * Validates a single ticket's stock with database locking.
	 *
	 * Acquires a row-level lock on the ticket's stock meta using SELECT FOR UPDATE.
	 *
	 * @since 5.26.7
	 *
	 * @param int $ticket_id The ticket ID.
	 * @param int $quantity  The requested quantity.
	 *
	 * @return true|WP_Error True if stock is available, WP_Error if not.
	 */
	protected function validate_ticket_stock_with_lock( int $ticket_id, int $quantity ) {
		try {
			$stock_meta_key = Ticket::$stock_meta_key;

			// In test environments with fake transactions, skip the FOR UPDATE clause.
			// The FOR UPDATE requires a real transaction context to work properly.
			$use_locking = ! ( defined( 'TRIBE_TESTS_HOME_URL' ) || function_exists( 'tec_tickets_tests_fake_transactions_enable' ) );

			if ( $use_locking ) {
				// Production: Lock the stock meta row for this ticket.
				$locked_stock = DB::get_var(
					DB::prepare(
						'SELECT meta_value FROM %i WHERE post_id = %d AND meta_key = %s FOR UPDATE',
						DB::prefix( 'postmeta' ),
						$ticket_id,
						$stock_meta_key
					)
				);
			} else {
				// Test environment: Use regular SELECT without locking.
				$locked_stock = DB::get_var(
					DB::prepare(
						'SELECT meta_value FROM %i WHERE post_id = %d AND meta_key = %s',
						DB::prefix( 'postmeta' ),
						$ticket_id,
						$stock_meta_key
					)
				);
			}

			// If no stock meta exists yet, treat as null (not 0).
			// This handles tickets that haven't had stock set yet.
			if ( false === $locked_stock ) {
				$locked_stock = null;
			}

			// Now validate with the locked value.
			return $this->validate_ticket_stock( $ticket_id, $quantity, $locked_stock );

		} catch ( DatabaseQueryException $e ) {
			return new WP_Error(
				'tec-tc-stock-lock-failed',
				__( 'Unable to verify ticket availability. Please try again.', 'event-tickets' ),
				[
					'ticket_id' => $ticket_id,
					'quantity'  => $quantity,
					'error'     => $e->getMessage(),
				]
			);
		}
	}

	/**
	 * Validates a single ticket's stock without locking.
	 *
	 * Used when validation occurs within an existing transaction/lock context.
	 *
	 * @since 5.26.7
	 *
	 * @param int      $ticket_id    The ticket ID.
	 * @param int      $quantity     The requested quantity.
	 * @param int|null $locked_stock Optional. Already-locked stock value.
	 *
	 * @return true|WP_Error True if stock is available, WP_Error if not.
	 */
	protected function validate_ticket_stock( int $ticket_id, int $quantity, $locked_stock = null ) {
		$ticket = Tribe__Tickets__Tickets::load_ticket_object( $ticket_id );

		// Bail early if ticket is invalid.
		if ( null === $ticket ) {
			return new WP_Error(
				'tec-tc-invalid-ticket',
				__( 'Invalid ticket.', 'event-tickets' ),
				[
					'ticket_id' => $ticket_id,
					'quantity'  => $quantity,
				]
			);
		}

		// Bail early for seated tickets - they have their own stock management via the seating service.
		if ( metadata_exists( 'post', $ticket_id, '_tec_slr_seat_type' ) ) {
			return true;
		}

		// Bail early for tickets that don't manage stock.
		if ( ! $ticket->manage_stock() ) {
			return true;
		}

		// Bail early for unlimited capacity tickets.
		if ( -1 === $ticket->capacity() ) {
			return true;
		}

		// Bail early for shared capacity (global stock) tickets.
		$global_stock_mode  = $ticket->global_stock_mode();
		$is_shared_capacity = ! empty( $global_stock_mode ) && 'own' !== $global_stock_mode;
		if ( $is_shared_capacity ) {
			return true;
		}

		// Use locked stock value if provided, otherwise get current stock.
		$available_stock = null !== $locked_stock ? (int) $locked_stock : $ticket->stock();

		// Bail early if stock is sufficient.
		if ( $available_stock >= $quantity ) {
			return true;
		}

		// Stock is insufficient - return error with data for user-facing message generation.
		return new WP_Error(
			'tec-tc-insufficient-stock',
			sprintf(
				'Stock validation failed: Ticket ID %1$d (%2$s) - requested %3$d, available %4$d',
				$ticket_id,
				$ticket->name,
				$quantity,
				max( 0, $available_stock )
			),
			[
				'ticket_id'    => $ticket_id,
				'ticket_name'  => $ticket->name,
				'requested'    => $quantity,
				'available'    => max( 0, $available_stock ),
				'insufficient' => true,
			]
		);
	}

	/**
	 * Builds a user-friendly WP_Error from validation errors.
	 *
	 * @since 5.26.7
	 *
	 * @param array $validation_errors Array of error data.
	 *
	 * @return WP_Error
	 */
	protected function build_insufficient_stock_error( array $validation_errors ): WP_Error {
		$error_messages = [];

		foreach ( $validation_errors as $error_data ) {
			if ( ! isset( $error_data['insufficient'] ) || ! $error_data['insufficient'] ) {
				continue;
			}

			$ticket_name = Arr::get( $error_data, 'ticket_name', __( 'Unknown Ticket', 'event-tickets' ) );
			$requested   = Arr::get( $error_data, 'requested', 0 );
			$available   = Arr::get( $error_data, 'available', 0 );

			if ( 0 === $available ) {
				$error_messages[] = sprintf(
					/* translators: 1: ticket name, 2: requested quantity */
					__( 'Sorry, "%1$s" tickets are sold out. You requested %2$d.', 'event-tickets' ),
					$ticket_name,
					$requested
				);
			} elseif ( 1 === $available ) {
				$error_messages[] = sprintf(
					/* translators: 1: ticket name, 2: requested quantity */
					__( 'Sorry, some "%1$s" tickets are no longer available. You requested %2$d and there is only 1 available.', 'event-tickets' ),
					$ticket_name,
					$requested
				);
			} else {
				$error_messages[] = sprintf(
					/* translators: 1: ticket name, 2: requested quantity, 3: available quantity */
					__( 'Sorry, some "%1$s" tickets are no longer available. You requested %2$d and there are only %3$d available.', 'event-tickets' ),
					$ticket_name,
					$requested,
					$available
				);
			}
		}

		$main_message = implode( ' ', $error_messages );

		return new WP_Error(
			'tec-tc-insufficient-stock',
			$main_message,
			[
				'status'            => 400,
				'validation_errors' => $validation_errors,
			]
		);
	}

	/**
	 * Determines if stock validation should occur for a status transition.
	 *
	 * @since 5.26.7
	 *
	 * @param Status\Status_Interface $new_status The status being transitioned to.
	 *
	 * @return bool
	 */
	public function should_validate_for_transition( Status\Status_Interface $new_status ): bool {
		// Get the configured stock handling status.
		$stock_handling_status = tribe( Status_Handler::class )->get_inventory_decrease_status();

		// Check if the new status is the one configured for stock decrease.
		if ( $new_status->get_slug() !== $stock_handling_status->get_slug() ) {
			return false;
		}

		// Additional check: ensure this status actually has the decrease_stock flag.
		return $new_status->has_flags( [ 'decrease_stock' ], 'AND' );
	}
}