# woocommerce-pos/1.10.16/includes/API/V1/Templates_Controller.php

WCPOS – Point of Sale (POS) plugin for WooCommerce, version 1.10.16. 1,566 lines.

- Page: https://pluginprobe.com/plugins/woocommerce-pos/1.10.16/code/includes/API/V1/Templates_Controller.php
- Raw: https://pluginprobe.com/plugins/woocommerce-pos/1.10.16/raw/includes/API/V1/Templates_Controller.php
- Modified: 2026-08-25T07:52:20+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/woocommerce-pos/1.10.16/code/includes/API/V1/Templates_Controller.php#L10-L20`.

```php
<?php
/**
 * Templates_Controller.
 *
 * @package WCPOS\WooCommercePOS
 */

namespace WCPOS\WooCommercePOS\API\V1;

use Ramsey\Uuid\Uuid;
use WCPOS\WooCommercePOS\Logger;
use WCPOS\WooCommercePOS\Services\Preview_Receipt_Builder;
use WCPOS\WooCommercePOS\Services\Receipt_Preview_Fixture_Loader;
use WCPOS\WooCommercePOS\Services\Receipt_Data_Builder;
use WCPOS\WooCommercePOS\Services\Receipt_Data_Schema;
use WCPOS\WooCommercePOS\Templates as TemplatesManager;
use WP_Error;
use WP_Query;
use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

use const WCPOS\WooCommercePOS\SHORT_NAME;

/**
 * Class Templates REST API Controller.
 *
 * Returns both virtual (filesystem) templates and custom (database) templates.
 */
class Templates_Controller extends WP_REST_Controller {
	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = SHORT_NAME . '/v1';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'templates';

	/**
	 * Register routes.
	 *
	 * Fixed paths must be registered before regex patterns to avoid
	 * the wildcard (?P<id>[\w-]+) capturing "active", "gallery", etc.
	 *
	 * @return void
	 */
	public function register_routes(): void {
		// 1. GET /templates (collection).
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => array( $this, 'get_items' ),
				'permission_callback' => array( $this, 'get_items_permissions_check' ),
				'args'                => $this->get_collection_params(),
			)
		);

		// 2. GET /templates/active (fixed path).
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/active',
			array(
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => array( $this, 'get_active' ),
				'permission_callback' => array( $this, 'get_item_permissions_check' ),
				'args'                => array(
					'type' => array(
						'description' => /* translators: REST API schema field label or error message. */ __( 'Template type.', 'woocommerce-pos' ),
						'type'        => 'string',
						'default'     => 'receipt',
						'enum'        => array( 'receipt', 'report' ),
					),
				),
			)
		);

		// 3. GET /templates/gallery (fixed path).
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/gallery',
			array(
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => array( $this, 'get_gallery_items' ),
				'permission_callback' => array( $this, 'get_items_permissions_check' ),
				'args'                => array(
					'type'     => array(
						'description'       => __( 'Filter by template type.', 'woocommerce-pos' ),
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
						'validate_callback' => 'rest_validate_request_arg',
					),
					'category' => array(
						'description'       => __( 'Filter by template category slug.', 'woocommerce-pos' ),
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
						'validate_callback' => 'rest_validate_request_arg',
					),
				),
			)
		);

		// 4. POST /templates/batch (fixed path).
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/batch',
			array(
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'batch_items' ),
				'permission_callback' => array( $this, 'update_item_permissions_check' ),
				'args'                => array(
					'type'            => array(
						'description'       => /* translators: REST API schema field label or error message. */ __( 'Template type for ordering.', 'woocommerce-pos' ),
						'type'              => 'string',
						'default'           => 'receipt',
						'enum'              => array( 'receipt', 'report' ),
						'sanitize_callback' => 'sanitize_text_field',
						'validate_callback' => 'rest_validate_request_arg',
					),
					'update'          => array(
						'description' => __( 'Array of templates to update.', 'woocommerce-pos' ),
						'type'        => 'array',
						'required'    => false,
						'items'       => array(
							'type'       => 'object',
							'properties' => array(
								'id'          => array(
									'type'     => 'integer',
									'required' => true,
								),
								'status'      => array(
									'type' => 'string',
									'enum' => array( 'publish', 'draft' ),
								),
								'menu_order'  => array( 'type' => 'integer' ),
								'tax_display' => array(
									'type' => 'string',
									'enum' => array( 'default', 'incl', 'excl' ),
								),
							),
						),
					),
					'order'           => array(
						'description' => /* translators: REST API schema field label or error message. */ __( 'Ordered array of all template IDs (int for database, string for virtual).', 'woocommerce-pos' ),
						'type'        => 'array',
						'items'       => array(
							'type' => array( 'integer', 'string' ),
						),
					),
					'disable_virtual' => array(
						'description' => __( 'Array of virtual template IDs to disable.', 'woocommerce-pos' ),
						'type'        => 'array',
						'items'       => array( 'type' => 'string' ),
					),
					'enable_virtual'  => array(
						'description' => __( 'Array of virtual template IDs to enable.', 'woocommerce-pos' ),
						'type'        => 'array',
						'items'       => array( 'type' => 'string' ),
					),
				),
			)
		);

		// 5. POST /templates/install (fixed path).
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/install',
			array(
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'install_gallery_item' ),
				'permission_callback' => array( $this, 'update_item_permissions_check' ),
				'args'                => array(
					'gallery_key' => array(
						'description'       => __( 'Gallery template key to install.', 'woocommerce-pos' ),
						'type'              => 'string',
						'required'          => true,
						'sanitize_callback' => 'sanitize_text_field',
						'validate_callback' => 'rest_validate_request_arg',
					),
				),
			)
		);

		// 6. GET /templates/{id} (regex). Must come after all fixed-path routes.
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[\w-]+)',
			array(
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => array( $this, 'get_item' ),
				'permission_callback' => array( $this, 'get_item_permissions_check' ),
				'args'                => array(
					'id'   => array(
						'description' => /* translators: REST API schema field label or error message. */ __( 'Unique identifier for the template (numeric for database, string for virtual).', 'woocommerce-pos' ),
						'type'        => 'string',
						'required'    => true,
					),
					'type' => array(
						'description'       => /* translators: REST API schema field label or error message. */ __( 'Template type.', 'woocommerce-pos' ),
						'type'              => 'string',
						'default'           => 'receipt',
						'enum'              => array( 'receipt', 'report' ),
						'sanitize_callback' => 'sanitize_key',
						'validate_callback' => 'rest_validate_request_arg',
					),
				),
			)
		);

		// 8. PATCH /templates/{id} (regex).
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[\d]+)',
			array(
				'methods'             => WP_REST_Server::EDITABLE,
				'callback'            => array( $this, 'update_item' ),
				'permission_callback' => array( $this, 'update_item_permissions_check' ),
				'args'                => array(
					'id'          => array(
						'type' => 'integer',
						'required' => true,
					),
					'status'      => array(
						'type' => 'string',
						'enum' => array( 'publish', 'draft' ),
					),
					'menu_order'  => array( 'type' => 'integer' ),
					'tax_display' => array(
						'type' => 'string',
						'enum' => array( 'default', 'incl', 'excl' ),
					),
				),
			)
		);

		// 9. POST /templates/{id}/copy (regex).
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[\d]+)/copy',
			array(
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'copy_item' ),
				'permission_callback' => array( $this, 'update_item_permissions_check' ),
				'args'                => array(
					'id' => array(
						'description' => /* translators: REST API schema field label or error message. */ __( 'Template ID to copy.', 'woocommerce-pos' ),
						'type'        => 'integer',
						'required'    => true,
					),
				),
			)
		);

		// 10. GET /templates/{id}/preview (regex).
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[\w-]+)/preview',
			array(
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => array( $this, 'preview_item' ),
				'permission_callback' => array( $this, 'preview_item_permissions_check' ),
				'args'                => array(
					'id'       => array(
						'description' => /* translators: REST API schema field label or error message. */ __( 'Template ID to preview.', 'woocommerce-pos' ),
						'type'        => 'string',
						'required'    => true,
					),
					'order_id'  => array(
						'description' => __( 'Order ID or "latest" for most recent POS order. Omit for sample data.', 'woocommerce-pos' ),
						'type'        => array( 'integer', 'string' ),
						'required'    => false,
						'default'     => 0,
					),
					'store_id'  => array(
						'description' => __( 'POS store ID for store-specific data.', 'woocommerce-pos' ),
						'type'        => 'integer',
						'required'    => false,
						'default'     => 0,
					),
					'include_legacy_html' => array(
						'description'       => __( 'Include temporary PHP-rendered HTML diagnostics for logicless previews.', 'woocommerce-pos' ),
						'type'              => 'boolean',
						'required'          => false,
						'default'           => false,
						'sanitize_callback' => 'rest_sanitize_boolean',
						'validate_callback' => 'rest_validate_request_arg',
					),
				),
			)
		);

		// 11. DELETE /templates/{id} (regex). Must come after all {id}/sub-path routes.
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[\d]+)',
			array(
				'methods'             => WP_REST_Server::DELETABLE,
				'callback'            => array( $this, 'delete_item' ),
				'permission_callback' => array( $this, 'update_item_permissions_check' ),
				'args'                => array(
					'id' => array(
						'description'       => /* translators: REST API schema field label or error message. */ __( 'Template ID to delete.', 'woocommerce-pos' ),
						'type'              => 'integer',
						'required'          => true,
						'validate_callback' => function ( $value ) {
							return is_numeric( $value );
						},
					),
				),
			)
		);
	}

	/**
	 * Get a collection of templates.
	 * Returns virtual templates first, then database templates.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 *
	 * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
	 */
	public function get_items( $request ) {
		$type           = $request->get_param( 'type' ) ?? 'receipt';
		$store_id       = $request->get_param( 'store_id' );
		$search         = $request->get_param( 'search' );
		$category       = $request->get_param( 'category' );
		$modified_after = $request->get_param( 'modified_after' );
		$per_page       = (int) ( $request->get_param( 'per_page' ) ?? -1 );
		$page           = max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) );
		$has_filters    = ( null !== $search && '' !== $search ) || ( null !== $category && '' !== $category ) || ( null !== $modified_after && '' !== $modified_after );

		// Step 1: Resolve which template is active. Store-specific requests keep the
		// POS runtime behavior of returning enabled templates only. The admin list
		// only includes inactive templates for managers so POS/runtime callers with
		// access_woocommerce_pos do not receive disabled or draft templates.
		$is_manager = current_user_can( 'manage_woocommerce_pos' );
		$enabled    = $store_id ? TemplatesManager::resolve_templates( (int) $store_id, $type ) : array();
		$active_template_id = $store_id
			? ( ! empty( $enabled ) ? $enabled[0]['id'] : null )
			: TemplatesManager::get_active_template_id( $type );

		// Step 2: Build full template list.
		// When store_id is set, use the resolved enabled list directly.
		// Managers without filters get the full admin list (including inactive).
		// Non-manager callers without store_id keep the previous enabled-only list.
		// When filters are active, query database templates with filters.
		if ( $store_id || ! $has_filters ) {
			if ( $store_id ) {
				$source_templates = $enabled;
			} elseif ( $is_manager ) {
				$source_templates = $this->get_admin_template_list( $type );
			} else {
				$source_templates = TemplatesManager::get_enabled_templates( $type );
			}
			$templates = array();
			foreach ( $source_templates as $template ) {
				$template['is_active'] = ( null !== $active_template_id && (string) $template['id'] === (string) $active_template_id );
				$templates[]           = $this->prepare_item_for_response( $template, $request );
			}

			$total_items = \count( $templates );

			if ( $per_page > 0 ) {
				$offset      = ( $page - 1 ) * $per_page;
				$templates   = \array_slice( $templates, $offset, $per_page );
				$total_pages = $total_items > 0 ? (int) \ceil( $total_items / $per_page ) : 1;
			} else {
				$total_pages = 1;
			}
		} else {
			// Filtered DB query (search, category, modified_after).
			$args = array(
				'post_type'      => 'wcpos_template',
				'post_status'    => $is_manager ? array( 'publish', 'draft' ) : 'publish',
				'posts_per_page' => $per_page,
				'paged'          => $page,
				'orderby'        => 'menu_order',
				'order'          => 'ASC',
			);

			$tax_query = array();
			if ( $type ) {
				$tax_query[] = array(
					'taxonomy' => 'wcpos_template_type',
					'field'    => 'slug',
					'terms'    => $type,
				);
			}
			if ( $category ) {
				$tax_query[] = array(
					'taxonomy' => 'wcpos_template_category',
					'field'    => 'slug',
					'terms'    => $category,
				);
			}
			if ( ! empty( $tax_query ) ) {
				if ( \count( $tax_query ) > 1 ) {
					$tax_query['relation'] = 'AND';
				}
				$args['tax_query'] = $tax_query;
			}
			if ( null !== $search && '' !== $search ) {
				$matching_ids     = $this->get_search_matching_template_ids( $search, $args );
				$args['post__in'] = empty( $matching_ids ) ? array( 0 ) : $matching_ids;
			}
			if ( $modified_after ) {
				$args['date_query'] = array(
					array(
						'column' => 'post_modified',
						'after'  => $modified_after,
					),
				);
			}

			$query     = new WP_Query( $args );
			$templates = array();
			foreach ( $query->posts as $post ) {
				$template = TemplatesManager::get_template( $post->ID );
				if ( $template ) {
					$template['is_active'] = ( null !== $active_template_id && (string) $template['id'] === (string) $active_template_id );
					$templates[]           = $this->prepare_item_for_response( $template, $request );
				}
			}

			$total_items = (int) $query->found_posts;
			$total_pages = (int) max( 1, $query->max_num_pages );
		}

		$response = rest_ensure_response( $templates );
		$response->header( 'X-WP-Total', (string) $total_items );
		$response->header( 'X-WP-TotalPages', (string) max( 1, $total_pages ) );

		return $response;
	}

	/**
	 * Get a single template.
	 * Supports both numeric IDs (database) and string IDs (virtual).
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 *
	 * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
	 */
	public function get_item( $request ) {
		$id   = $request['id'];
		$type = $request->get_param( 'type' ) ?? 'receipt';

		// Check if it's a numeric ID (database template).
		if ( is_numeric( $id ) ) {
			$template = TemplatesManager::get_template( (int) $id );
		} else {
			// It's a virtual template ID.
			$template = TemplatesManager::get_virtual_template( $id, $type );
		}

		if ( ! $template ) {
			return new WP_Error(
				'wcpos_template_invalid_id',
				/* translators: REST API schema field label or error message. */
				__( 'Invalid template ID.', 'woocommerce-pos' ),
				array( 'status' => 404 )
			);
		}

		$enabled = TemplatesManager::get_enabled_templates( $template['type'] ?? 'receipt' );
		$active_id = ! empty( $enabled ) ? $enabled[0]['id'] : null;
		$template['is_active'] = ( null !== $active_id && (string) $template['id'] === (string) $active_id );

		return rest_ensure_response( $this->prepare_item_for_response( $template, $request ) );
	}

	/**
	 * Get the active template for a type.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 *
	 * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
	 */
	public function get_active( $request ) {
		$type     = $request->get_param( 'type' ) ?? 'receipt';
		$template = TemplatesManager::get_active_template( $type );

		if ( ! $template ) {
			return new WP_Error(
				'wcpos_no_active_template',
				__( 'No active template found.', 'woocommerce-pos' ),
				array( 'status' => 404 )
			);
		}

		$template['is_active'] = true;

		return rest_ensure_response( $this->prepare_item_for_response( $template, $request ) );
	}

	/**
	 * Update a single template.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 *
	 * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
	 */
	public function update_item( $request ) {
		$id   = (int) $request['id'];
		$post = get_post( $id );

		if ( ! $post || 'wcpos_template' !== $post->post_type ) {
			return new WP_Error(
				'wcpos_template_invalid_id',
				/* translators: REST API schema field label or error message. */
				__( 'Invalid template ID.', 'woocommerce-pos' ),
				array( 'status' => 404 )
			);
		}

		$update_args = array( 'ID' => $id );
		$needs_update = false;

		// Update status.
		$status = $request->get_param( 'status' );
		if ( null !== $status ) {
			$update_args['post_status'] = $status;
			$needs_update = true;
		}

		// Update menu_order.
		$menu_order = $request->get_param( 'menu_order' );
		if ( null !== $menu_order ) {
			$update_args['menu_order'] = (int) $menu_order;
			$needs_update = true;
		}

		if ( $needs_update ) {
			$result = wp_update_post( $update_args, true );
			if ( is_wp_error( $result ) ) {
				return $result;
			}
		}

		// Update tax_display meta.
		$tax_display = $request->get_param( 'tax_display' );
		if ( null !== $tax_display ) {
			update_post_meta( $id, '_template_tax_display', $tax_display );
		}

		$template = TemplatesManager::get_template( $id );
		if ( ! $template ) {
			return new WP_Error(
				'wcpos_template_not_found',
				/* translators: REST API schema field label or error message. */
				__( 'Template not found after update.', 'woocommerce-pos' ),
				array( 'status' => 500 )
			);
		}

		$enabled = TemplatesManager::get_enabled_templates( $template['type'] ?? 'receipt' );
		$active_id = ! empty( $enabled ) ? $enabled[0]['id'] : null;
		$template['is_active'] = ( null !== $active_id && (string) $template['id'] === (string) $active_id );

		return rest_ensure_response( $this->prepare_item_for_response( $template, $request ) );
	}

	/**
	 * Batch update templates.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 *
	 * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
	 */
	public function batch_items( $request ) {
		$type = $request->get_param( 'type' ) ?? 'receipt';

		// Handle order.
		$order = $request->get_param( 'order' );
		if ( \is_array( $order ) ) {
			TemplatesManager::save_template_order( $order, $type );
		}

		// Handle disable_virtual.
		$disable_virtual = $request->get_param( 'disable_virtual' );
		if ( \is_array( $disable_virtual ) ) {
			foreach ( $disable_virtual as $vid ) {
				if ( \is_string( $vid ) ) {
					TemplatesManager::set_virtual_template_disabled( $vid, true, $type );
				}
			}
		}

		// Handle enable_virtual.
		$enable_virtual = $request->get_param( 'enable_virtual' );
		if ( \is_array( $enable_virtual ) ) {
			foreach ( $enable_virtual as $vid ) {
				if ( \is_string( $vid ) ) {
					TemplatesManager::set_virtual_template_disabled( $vid, false, $type );
				}
			}
		}

		// Handle update (existing logic for database templates).
		$updates = $request->get_param( 'update' );
		$results = array();

		if ( \is_array( $updates ) ) {
			foreach ( $updates as $index => $item ) {
				if ( ! \is_array( $item ) || empty( $item['id'] ) || ! \is_numeric( $item['id'] ) ) {
					$results[] = array(
						'id'    => \is_array( $item ) ? ( $item['id'] ?? null ) : null,
						'error' => array(
							'code'    => 'wcpos_template_missing_id',
							/* translators: %d: batch item index. */
							'message' => sprintf( __( 'Batch item %d must include a numeric id.', 'woocommerce-pos' ), $index + 1 ),
						),
					);
					continue;
				}

				$item_id = (int) $item['id'];

				$item_request = new WP_REST_Request( 'PATCH' );
				$item_request->set_body_params( $item );
				$item_request->set_url_params( array( 'id' => $item_id ) );

				$result = $this->update_item( $item_request );

				if ( is_wp_error( $result ) ) {
					$results[] = array(
						'id'    => $item_id,
						'error' => array(
							'code'    => $result->get_error_code(),
							'message' => $result->get_error_message(),
						),
					);
				} else {
					$results[] = $result->get_data();
				}
			}
		}

		// Build response.
		$response_data = array();
		if ( ! empty( $results ) ) {
			$response_data['update'] = $results;
		}
		if ( \is_array( $order ) ) {
			$response_data['order'] = TemplatesManager::get_template_order( $type );
		}
		if ( \is_array( $disable_virtual ) || \is_array( $enable_virtual ) ) {
			$response_data['disabled_virtual'] = TemplatesManager::get_disabled_virtual_templates( $type );
		}

		$response = rest_ensure_response( $response_data );

		// Return 400 only when the request contained nothing but update items and every one failed.
		$has_non_update_ops = \is_array( $order ) || \is_array( $disable_virtual ) || \is_array( $enable_virtual );
		if ( ! empty( $results ) && ! $has_non_update_ops ) {
			$has_success = false;
			foreach ( $results as $result_item ) {
				if ( ! isset( $result_item['error'] ) ) {
					$has_success = true;
					break;
				}
			}

			if ( ! $has_success ) {
				$response->set_status( 400 );
			}
		}

		return $response;
	}

	/**
	 * Copy a template.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 *
	 * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
	 */
	public function copy_item( $request ) {
		$id       = (int) $request['id'];
		$template = TemplatesManager::get_template( $id );

		if ( ! $template ) {
			return new WP_Error(
				'wcpos_template_invalid_id',
				/* translators: REST API schema field label or error message. */
				__( 'Invalid template ID.', 'woocommerce-pos' ),
				array( 'status' => 404 )
			);
		}

		$source_post = get_post( $id );

		// Create the copy.
		$new_post_id = wp_insert_post(
			array(
				/* translators: %s: original template title */
				'post_title'   => sprintf( __( 'Copy of %s', 'woocommerce-pos' ), $source_post->post_title ),
				'post_content' => $source_post->post_content,
				'post_status'  => 'draft',
				'post_type'    => 'wcpos_template',
				'menu_order'   => $source_post->menu_order,
			),
			true
		);

		if ( is_wp_error( $new_post_id ) ) {
			return $new_post_id;
		}

		// Copy taxonomies.
		$taxonomies = get_object_taxonomies( 'wcpos_template' );
		foreach ( $taxonomies as $taxonomy ) {
			$terms = wp_get_object_terms( $id, $taxonomy, array( 'fields' => 'slugs' ) );
			if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) {
				wp_set_object_terms( $new_post_id, $terms, $taxonomy );
			}
		}

		// Copy meta fields.
		$meta_keys = array(
			'_template_description',
			'_template_language',
			'_template_engine',
			'_template_output_type',
			'_template_tax_display',
			'_template_paper_width',
		);

		foreach ( $meta_keys as $meta_key ) {
			$value = get_post_meta( $id, $meta_key, true );
			if ( '' !== $value ) {
				update_post_meta( $new_post_id, $meta_key, $value );
			}
		}

		// Bypass wp_kses for offline-capable engines — it strips unknown HTML/XML tags.
		$engine = $template['engine'] ?? 'legacy-php';
		if ( \in_array( $engine, TemplatesManager::OFFLINE_CAPABLE_ENGINES, true ) ) {
			if ( ! TemplatesManager::save_raw_post_content( $new_post_id, $source_post->post_content ) ) {
				wp_delete_post( $new_post_id, true );

				return new WP_Error(
					'wcpos_template_copy_failed',
					__( 'Failed to save copied template content.', 'woocommerce-pos' ),
					array( 'status' => 500 )
				);
			}
		}

		$new_template = TemplatesManager::get_template( $new_post_id );
		if ( ! $new_template ) {
			return new WP_Error(
				'wcpos_template_copy_failed',
				__( 'Failed to retrieve copied template.', 'woocommerce-pos' ),
				array( 'status' => 500 )
			);
		}

		$new_template['is_active'] = false;

		$response = rest_ensure_response( $this->prepare_item_for_response( $new_template, $request ) );
		$response->set_status( 201 );

		return $response;
	}

	/**
	 * Delete a custom template.
	 *
	 * Virtual (filesystem) templates cannot be deleted.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 *
	 * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
	 */
	public function delete_item( $request ) {
		$id       = (int) $request['id'];
		$template = TemplatesManager::get_template( $id );

		if ( ! $template ) {
			return new WP_Error(
				'wcpos_template_invalid_id',
				/* translators: REST API schema field label or error message. */
				__( 'Invalid template ID.', 'woocommerce-pos' ),
				array( 'status' => 404 )
			);
		}

		if ( ! empty( $template['is_virtual'] ) ) {
			return new WP_Error(
				'wcpos_template_cannot_delete',
				__( 'Built-in templates cannot be deleted.', 'woocommerce-pos' ),
				array( 'status' => 403 )
			);
		}

		$deleted = wp_delete_post( $id, true );

		if ( ! $deleted ) {
			return new WP_Error(
				'wcpos_template_delete_failed',
				__( 'Failed to delete template.', 'woocommerce-pos' ),
				array( 'status' => 500 )
			);
		}

		return rest_ensure_response(
			array(
				'deleted' => true,
				'id'      => $id,
			)
		);
	}

	/**
	 * Install a gallery template.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 *
	 * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
	 */
	public function install_gallery_item( $request ) {
		$gallery_key = $request->get_param( 'gallery_key' );
		$result      = TemplatesManager::install_gallery_template( $gallery_key );

		if ( is_wp_error( $result ) ) {
			$status = $this->get_wp_error_status( $result, 400 );
			$result->add_data( array( 'status' => $status ) );
			return $result;
		}

		$template = TemplatesManager::get_template( $result );
		if ( ! $template ) {
			return new WP_Error(
				'wcpos_template_install_failed',
				__( 'Template was installed but could not be retrieved.', 'woocommerce-pos' ),
				array( 'status' => 500 )
			);
		}

		$template['is_active'] = false;

		$response = rest_ensure_response( $this->prepare_item_for_response( $template, $request ) );
		$response->set_status( 201 );

		return $response;
	}

	/**
	 * Preview a template.
	 *
	 * Returns a preview URL for the template rendered with a sample POS order.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 *
	 * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
	 */
	public function preview_item( $request ) {
		$id   = $request['id'];
		$type = $request->get_param( 'type' ) ?? 'receipt';

		// Validate the template exists (database, virtual, or gallery).
		if ( is_numeric( $id ) ) {
			$template = TemplatesManager::get_template( (int) $id );
		} else {
			$template = TemplatesManager::get_virtual_template( $id, $type );
			if ( ! $template ) {
				$template = TemplatesManager::get_gallery_template_by_key( $id );
			}
		}

		if ( ! $template ) {
			return new WP_Error(
				'wcpos_template_invalid_id',
				/* translators: REST API schema field label or error message. */
				__( 'Invalid template ID.', 'woocommerce-pos' ),
				array( 'status' => 404 )
			);
		}

		// Build receipt data: real order if order_id provided, otherwise sample data.
		$raw_order_id = $request->get_param( 'order_id' );
		$order        = null;
		$order_id     = 0;

		if ( 'latest' === $raw_order_id ) {
			$latest = wc_get_orders(
				array(
					'limit'       => 1,
					'orderby'     => 'date',
					'order'       => 'DESC',
					'status'      => array( 'completed', 'processing', 'on-hold', 'pending' ),
					'created_via' => 'woocommerce-pos',
				)
			);
			if ( ! empty( $latest ) ) {
				$order    = $latest[0];
				$order_id = $order->get_id();
			}
		} elseif ( (int) $raw_order_id > 0 ) {
			$order_id = (int) $raw_order_id;
			$order    = wc_get_order( $order_id );
			if ( ! $order || ! \wcpos_is_pos_order( $order ) ) {
				return new WP_Error(
					'wcpos_invalid_order',
					__( 'Order not found or is not a POS order.', 'woocommerce-pos' ),
					array( 'status' => 404 )
				);
			}
		}

		$store_id          = (int) $request->get_param( 'store_id' );
		$request_pos_store = null;
		if ( $store_id > 0 ) {
			$request_pos_store = wcpos_get_store( $store_id );
			if ( ! \is_object( $request_pos_store ) ) {
				return new WP_Error(
					'wcpos_invalid_store',
					/* translators: REST API schema field label or error message. */
					__( 'Store not found.', 'woocommerce-pos' ),
					array( 'status' => 404 )
				);
			}
		}

		if ( $order ) {
			$receipt_data = ( new Receipt_Data_Builder() )->build( $order, 'live', $request_pos_store );
		} else {
			$pos_store = null === $request_pos_store ? wcpos_get_store() : $request_pos_store;
			$preview_data_profile = isset( $template['preview_data'] ) && is_string( $template['preview_data'] )
				? $template['preview_data']
				: null;
			$receipt_data = null !== $preview_data_profile
				? ( new Receipt_Preview_Fixture_Loader() )->build( $preview_data_profile, $pos_store )
				: ( new Preview_Receipt_Builder() )->build( $pos_store );
		}

		$currency       = $receipt_data['order']['currency'] ?? 'USD';
		$formatted_data = Receipt_Data_Schema::format_money_fields( $receipt_data, $currency );

		// Determine engine.
		$engine = $template['engine'] ?? 'legacy-php';

		// Thermal (ESC/POS) templates are rendered by the client using template_content + receipt_data.
		if ( 'thermal' === $engine ) {
			return rest_ensure_response( $this->prepare_non_legacy_preview_response( $template, $formatted_data, $order_id, $id ) );
		}

		// Non-thermal with a real order.
		if ( $order ) {
			if ( 'logicless' === $engine ) {
				// Server-render HTML for gallery/store-edit, keep receipt_data for the editor.
				$formatted_data['t'] = true;

				$response = $this->prepare_non_legacy_preview_response( $template, $formatted_data, $order_id, $id );

				try {
					// Keep preview_html for backward compatibility and diagnostics only.
					// It is passed through wp_kses_post(), which can strip layout-critical CSS
					// such as display:flex/display:grid. Template Studio and Template Gallery
					// must render visual previews from template_content + receipt_data instead.
					$response['preview_html'] = $this->render_logicless_preview( $template, $formatted_data );
				} catch ( \Mustache\Exception\SyntaxException $e ) {
					// Malformed template — still return receipt_data so the editor can render.
					unset( $e );
				}

				return rest_ensure_response( $response );
			}

			// Legacy-php needs a server-side iframe URL.
			$order_key      = $order->get_order_key();
			$preview_params = array(
				'key'                    => $order_key,
				'wcpos_preview_template' => $id,
			);
			if ( $store_id > 0 ) {
				$preview_params['store_id'] = $store_id;
			}
			$preview_url = add_query_arg(
				$preview_params,
				wcpos_checkout_url( 'wcpos-receipt/' . $order_id )
			);

			return rest_ensure_response(
				array(
					'engine'      => 'legacy-php',
					'preview_url' => $preview_url,
					'order_id'    => $order_id,
					'template_id' => $id,
				)
			);
		}

		// Sample data (no real order): render server-side for all engines.

		if ( 'logicless' === $engine ) {
			$response = $this->prepare_non_legacy_preview_response( $template, $formatted_data, 0, $id );

			try {
				// Keep preview_html for backward compatibility and diagnostics only.
				// It is passed through wp_kses_post(), which can strip layout-critical CSS
				// such as display:flex/display:grid. Template Studio and Template Gallery
				// must render visual previews from template_content + receipt_data instead.
				$response['preview_html'] = $this->render_logicless_preview( $template, $formatted_data );
			} catch ( \Mustache\Exception\SyntaxException $e ) {
				$response['preview_html'] = '<div style="padding:40px;text-align:center;font-family:sans-serif;color:#c00;">'
					. esc_html__( 'Mustache template syntax error. Check your template.', 'woocommerce-pos' )
					. '</div>';
			}

			return rest_ensure_response( $response );
		}

		// Legacy-php templates execute arbitrary PHP that expects a real WC_Order
		// in scope. Sample receipt data cannot stand in for one, so signal the
		// editor to prompt for a POS order instead of rendering with a null order.
		return rest_ensure_response(
			array(
				'engine'         => 'legacy-php',
				'requires_order' => true,
				'order_id'       => 0,
				'template_id'    => $id,
			)
		);
	}

	/**
	 * Prepare the normalized preview payload for non-legacy renderers.
	 *
	 * JS consumers render from template_content + receipt_data. Temporary diagnostic
	 * fields such as preview_html may be added by callers during the migration.
	 *
	 * @param array      $template       Template metadata.
	 * @param array      $receipt_data   Formatted receipt data.
	 * @param int        $order_id       Order ID, or 0 for sample data.
	 * @param int|string $template_id    Numeric database ID or virtual/gallery key.
	 *
	 * @return array<string, mixed>
	 */
	private function prepare_non_legacy_preview_response( array $template, array $receipt_data, int $order_id, $template_id ): array {
		return array(
			'engine'           => $template['engine'] ?? 'logicless',
			'template_content' => isset( $template['content'] ) && \is_string( $template['content'] ) ? $template['content'] : '',
			'receipt_data'     => $receipt_data,
			'paper_width'      => $template['paper_width'] ?? null,
			'order_id'         => $order_id,
			'template_id'      => is_numeric( $template_id ) ? (int) $template_id : (string) $template_id,
		);
	}


	/**
	 * Render a logicless template with preview data and return the HTML string.
	 *
	 * Uses output buffering to capture the Logicless_Renderer output
	 * without requiring a real WC_Abstract_Order.
	 *
	 * @param array $template       Template metadata including content.
	 * @param array $formatted_data Receipt data with money fields pre-formatted.
	 *
	 * @return string Rendered HTML.
	 */
	private function render_logicless_preview( array $template, array $formatted_data ): string {
		$content = isset( $template['content'] ) && \is_string( $template['content'] ) ? $template['content'] : '';

		if ( '' === $content ) {
			return '<!-- Empty logicless receipt template -->';
		}

		// Strip HTML comments — wp_kses_post removes the delimiters but leaves the text.
		$content = preg_replace( '/<!--.*?-->/s', '', $content );

		// Safety net for unresolved {{#t}}...{{/t}} markers in gallery templates.
		$formatted_data['t'] = true;

		$flags    = ENT_QUOTES | ENT_SUBSTITUTE;
		$mustache = new \Mustache\Engine(
			array(
				'entity_flags' => $flags,
				'escape'       => function ( $value ) use ( $flags ) {
					if ( \is_array( $value ) ) {
						return '';
					}

					return htmlspecialchars( (string) $value, $flags, 'UTF-8' );
				},
			)
		);

		$output = $mustache->render( $content, $formatted_data );

		return wp_kses_post( $output );
	}

	/**
	 * Get gallery templates.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 *
	 * @return WP_REST_Response Response object on success.
	 */
	public function get_gallery_items( $request ) {
		$type     = $request->get_param( 'type' );
		$category = $request->get_param( 'category' );

		$templates = TemplatesManager::get_gallery_templates( $type, $category );

		// Strip internal content_file and add UUID to gallery templates.
		$templates = array_map(
			function ( $template ) {
				unset( $template['content_file'] );
				$type              = $template['type'] ?? 'receipt';
				$id                = $template['key'] ?? $template['id'] ?? '';
				$template['uuid']  = $this->get_virtual_template_uuid( (string) $id, $type );
				return $template;
			},
			$templates
		);

		return rest_ensure_response( $templates );
	}

	/**
	 * Prepare template for response.
	 *
	 * @param array           $template Template data.
	 * @param WP_REST_Request $request  Request object.
	 *
	 * @return array|WP_REST_Response Prepared template data.
	 */
	public function prepare_item_for_response( $template, $request ) {
		$context = $request->get_param( 'context' ) ?? 'view';
		$engine  = $template['engine'] ?? 'legacy-php';

		// Add UUID.
		$template['uuid'] = $this->get_template_uuid( $template );

		// Add computed fields.
		$template['offline_capable'] = in_array( $engine, TemplatesManager::OFFLINE_CAPABLE_ENGINES, true );
		$template['menu_order']      = isset( $template['menu_order'] ) ? (int) $template['menu_order'] : 0;

		// Normalize date_modified_gmt to ISO-like format (Y-m-d\TH:i:s) for consistency with other endpoints.
		if ( isset( $template['date_modified_gmt'] ) && preg_match( '/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/', $template['date_modified_gmt'] ) ) {
			$template['date_modified_gmt'] = preg_replace( '/(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})/', '$1T$2', $template['date_modified_gmt'] );
		}

		// Content handling:
		// - In 'edit' context: always include content (for admin editor)
		// - In 'view' context: include content for offline-capable engines (logicless, thermal)
		// - PHP templates: strip content in view context (can't be rendered client-side).
		if ( 'edit' !== $context ) {
			if ( ! in_array( $engine, TemplatesManager::OFFLINE_CAPABLE_ENGINES, true ) ) {
				unset( $template['content'] );
			}
		}

		// Add is_disabled for virtual templates (scoped by type).
		if ( ! empty( $template['is_virtual'] ) ) {
			$type = $request->get_param( 'type' ) ?? 'receipt';
			$template['is_disabled'] = TemplatesManager::is_virtual_template_disabled( (string) $template['id'], $type );
		}

		return $template;
	}

	/**
	 * Get or create a UUID for a template.
	 *
	 * Database templates store a random UUID v4 in postmeta.
	 * Virtual templates use a deterministic UUID v5 derived from the template ID.
	 *
	 * @param array $template Template data.
	 *
	 * @return string UUID string.
	 */
	private function get_template_uuid( array $template ): string {
		if ( ! empty( $template['is_virtual'] ) ) {
			$type = $template['type'] ?? 'receipt';
			return $this->get_virtual_template_uuid( (string) $template['id'], $type );
		}

		return $this->get_database_template_uuid( (int) $template['id'] );
	}

	/**
	 * Generate a deterministic UUID v5 for a virtual template.
	 *
	 * Uses the URL namespace with a wcpos-specific prefix so the same
	 * template ID + type always produces the same UUID across installations.
	 * Type is included because the same ID (e.g. 'plugin-core') can exist
	 * for both 'receipt' and 'report' template types.
	 *
	 * @param string $template_id Virtual template ID (e.g. 'plugin-core').
	 * @param string $type        Template type (e.g. 'receipt', 'report').
	 *
	 * @return string UUID v5 string.
	 */
	private function get_virtual_template_uuid( string $template_id, string $type ): string {
		try {
			return Uuid::uuid5( Uuid::NAMESPACE_URL, 'https://wcpos.com/template/' . $type . '/' . $template_id )->toString();
		} catch ( \Exception $e ) {
			Logger::error( 'Virtual template UUID generation failed: ' . $e->getMessage() );
			return '';
		}
	}

	/**
	 * Get or create a UUID v4 for a database template.
	 *
	 * Stores the UUID in postmeta with the standard _woocommerce_pos_uuid key.
	 *
	 * @param int $post_id Template post ID.
	 *
	 * @return string UUID v4 string.
	 */
	private function get_database_template_uuid( int $post_id ): string {
		$uuid = get_post_meta( $post_id, '_woocommerce_pos_uuid', true );

		if ( is_string( $uuid ) && '' !== $uuid && Uuid::isValid( $uuid ) ) {
			return $uuid;
		}

		try {
			$uuid = Uuid::uuid4()->toString();
		} catch ( \Exception $e ) {
			Logger::error( 'Database template UUID generation failed: ' . $e->getMessage() );
			return '';
		}

		if ( add_post_meta( $post_id, '_woocommerce_pos_uuid', $uuid, true ) ) {
			return $uuid;
		}

		// Another request may have written a UUID concurrently — use it.
		$persisted_uuid = get_post_meta( $post_id, '_woocommerce_pos_uuid', true );
		if ( is_string( $persisted_uuid ) && '' !== $persisted_uuid && Uuid::isValid( $persisted_uuid ) ) {
			return $persisted_uuid;
		}

		Logger::error( 'Database template UUID persistence failed.', array( 'post_id' => $post_id ) );
		return '';
	}

	/**

	/**
	 * Get collection parameters.
	 *
	 * @return array Collection parameters.
	 */
	public function get_collection_params() {
		return array(
			'page'           => array(
				'description'       => __( 'Current page of the collection.', 'woocommerce-pos' ),
				'type'              => 'integer',
				'default'           => 1,
				'sanitize_callback' => 'absint',
				'validate_callback' => 'rest_validate_request_arg',
			),
			'per_page'       => array(
				'description'       => __( 'Maximum number of items to be returned in result set.', 'woocommerce-pos' ),
				'type'              => 'integer',
				'default'           => -1,
				'sanitize_callback' => array( $this, 'sanitize_per_page_param' ),
				'validate_callback' => array( $this, 'validate_per_page_param' ),
			),
			'type'           => array(
				'description'       => __( 'Filter by template type.', 'woocommerce-pos' ),
				'type'              => 'string',
				'default'           => 'receipt',
				'enum'              => array( 'receipt', 'report' ),
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => 'rest_validate_request_arg',
			),
			'context'        => array(
				'description'       => __( 'Scope under which the request is made.', 'woocommerce-pos' ),
				'type'              => 'string',
				'default'           => 'view',
				'enum'              => array( 'view', 'edit' ),
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => 'rest_validate_request_arg',
			),
			'search'         => array(
				'description'       => __( 'Search templates by title or description.', 'woocommerce-pos' ),
				'type'              => 'string',
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => 'rest_validate_request_arg',
			),
			'category'       => array(
				'description'       => __( 'Filter by template category slug.', 'woocommerce-pos' ),
				'type'              => 'string',
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => 'rest_validate_request_arg',
			),
			'modified_after' => array(
				'description'       => __( 'Limit to templates modified after this ISO 8601 date.', 'woocommerce-pos' ),
				'type'              => 'string',
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => 'rest_validate_request_arg',
			),
			'store_id'       => array(
				'description'       => __( 'Limit results to templates resolved for a specific store.', 'woocommerce-pos' ),
				'type'              => 'integer',
				'default'           => 0,
				'sanitize_callback' => 'absint',
				'validate_callback' => 'rest_validate_request_arg',
			),
		);
	}

	/**
	 * Get all templates for the admin list, including inactive templates.
	 *
	 * Runtime/store contexts use TemplatesManager::get_enabled_templates(); the
	 * admin gallery needs the broader list so draft posts and disabled virtual
	 * templates remain visible with their Active switch off.
	 *
	 * @param string $type Template type.
	 *
	 * @return array<int,array<string,mixed>> Template data arrays.
	 */
	private function get_admin_template_list( string $type ): array {
		$templates = TemplatesManager::detect_filesystem_templates( $type );

		$posts = get_posts(
			array(
				'post_type'      => 'wcpos_template',
				'post_status'    => array( 'publish', 'draft' ),
				'posts_per_page' => -1,
				'orderby'        => 'menu_order',
				'order'          => 'ASC',
				'tax_query'      => array(
					array(
						'taxonomy' => 'wcpos_template_type',
						'field'    => 'slug',
						'terms'    => $type,
					),
				),
			)
		);

		foreach ( $posts as $post ) {
			$template = TemplatesManager::get_template( $post->ID );
			if ( $template ) {
				$templates[] = $template;
			}
		}

		$order = TemplatesManager::get_template_order( $type );
		if ( ! empty( $order ) ) {
			$order_map          = array_flip( array_map( 'strval', $order ) );
			$original_positions = array();
			foreach ( $templates as $index => $template ) {
				$original_positions[ (string) $template['id'] ] = $index;
			}

			usort(
				$templates,
				function ( $a, $b ) use ( $order_map, $original_positions ) {
					$pos_a = $order_map[ (string) $a['id'] ] ?? PHP_INT_MAX;
					$pos_b = $order_map[ (string) $b['id'] ] ?? PHP_INT_MAX;

					if ( $pos_a === $pos_b ) {
						$idx_a = $original_positions[ (string) $a['id'] ] ?? PHP_INT_MAX;
						$idx_b = $original_positions[ (string) $b['id'] ] ?? PHP_INT_MAX;
						return $idx_a <=> $idx_b;
					}

					return $pos_a <=> $pos_b;
				}
			);
		}

		return $templates;
	}

	/**
	 * Get template IDs matching title/content search OR description meta search.
	 *
	 * @param string $search Search term.
	 * @param array  $args   Base query args (without search constraints).
	 *
	 * @return int[] Matching template IDs.
	 */
	private function get_search_matching_template_ids( string $search, array $args ): array {
		$base_query_args = $args;
		unset( $base_query_args['post__in'] );
		$base_query_args['fields']        = 'ids';
		$base_query_args['posts_per_page'] = -1;
		$base_query_args['paged']         = 1;
		$base_query_args['no_found_rows'] = true;

		$title_query_args      = $base_query_args;
		$title_query_args['s'] = $search;
		$title_query           = new WP_Query( $title_query_args );

		$description_query_args               = $base_query_args;
		$description_query_args['meta_query'] = array(
			array(
				'key'     => '_template_description',
				'value'   => $search,
				'compare' => 'LIKE',
			),
		);
		$description_query                    = new WP_Query( $description_query_args );

		return array_values( array_unique( array_merge( $title_query->posts, $description_query->posts ) ) );
	}

	/**
	 * Preserve -1 for "all items", otherwise sanitize as a positive integer.
	 *
	 * @param mixed $value Requested per_page value.
	 *
	 * @return int
	 */
	public function sanitize_per_page_param( $value ): int {
		$value = (int) $value;
		return -1 === $value ? -1 : absint( $value );
	}

	/**
	 * Validate per_page as either -1 or a positive integer.
	 *
	 * @param mixed $value Requested per_page value.
	 *
	 * @return bool
	 */
	public function validate_per_page_param( $value ): bool {
		$value = (int) $value;
		return -1 === $value || $value > 0;
	}

	/**
	 * Get a valid HTTP status code from WP_Error data.
	 *
	 * @param WP_Error $error         Error object.
	 * @param int      $fallback_code Fallback status code.
	 *
	 * @return int
	 */
	private function get_wp_error_status( WP_Error $error, int $fallback_code = 400 ): int {
		$error_data = $error->get_error_data();
		$status     = null;

		if ( \is_array( $error_data ) && isset( $error_data['status'] ) ) {
			$status = $error_data['status'];
		} elseif ( \is_numeric( $error_data ) ) {
			$status = $error_data;
		}

		if ( null === $status ) {
			return $fallback_code;
		}

		$status = (int) $status;
		return ( $status >= 100 && $status <= 599 ) ? $status : $fallback_code;
	}

	/**
	 * Check if a given request has access to read templates.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 *
	 * @return bool|WP_Error True if the request has access, WP_Error object otherwise.
	 */
	public function get_items_permissions_check( $request ) {
		if ( ! current_user_can( 'access_woocommerce_pos' ) ) {
			return new WP_Error(
				'wcpos_rest_cannot_view',
				__( 'Sorry, you cannot list templates.', 'woocommerce-pos' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}

		// The 'edit' context exposes full template content (including PHP); require manage capability.
		if ( 'edit' === $request->get_param( 'context' ) && ! current_user_can( 'manage_woocommerce_pos' ) ) {
			return new WP_Error(
				'wcpos_rest_cannot_edit',
				__( 'Sorry, you are not allowed to edit templates.', 'woocommerce-pos' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}

		return true;
	}

	/**
	 * Check if a given request has access to read a specific template.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 *
	 * @return bool|WP_Error True if the request has access, WP_Error object otherwise.
	 */
	public function get_item_permissions_check( $request ) {
		if ( ! current_user_can( 'access_woocommerce_pos' ) ) {
			return new WP_Error(
				'wcpos_rest_cannot_view',
				__( 'Sorry, you cannot view this template.', 'woocommerce-pos' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}

		// The 'edit' context exposes full template content (including PHP); require manage capability.
		if ( 'edit' === $request->get_param( 'context' ) && ! current_user_can( 'manage_woocommerce_pos' ) ) {
			return new WP_Error(
				'wcpos_rest_cannot_edit',
				__( 'Sorry, you are not allowed to edit this template.', 'woocommerce-pos' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}

		return true;
	}

	/**
	 * Check if a given request has access to preview a template.
	 *
	 * Preview returns order data, so it requires the stricter manage capability.
	 *
	 * @param WP_REST_Request $_request Full details about the request.
	 *
	 * @return bool|WP_Error True if the request has access, WP_Error object otherwise.
	 */
	public function preview_item_permissions_check( $_request ) {
		if ( ! current_user_can( 'manage_woocommerce_pos' ) ) {
			return new WP_Error(
				'wcpos_rest_cannot_view',
				__( 'Sorry, you cannot preview this template.', 'woocommerce-pos' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}

		return true;
	}

	/**
	 * Check if a given request has access to update templates.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 *
	 * @return bool|WP_Error True if the request has access, WP_Error object otherwise.
	 */
	public function update_item_permissions_check( $request ) {
		if ( ! current_user_can( 'manage_woocommerce_pos' ) ) {
			return new WP_Error(
				'wcpos_rest_cannot_update',
				__( 'Sorry, you cannot update templates.', 'woocommerce-pos' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}

		return true;
	}
}

```
