Commit fd75e24dbc for woocommerce

commit fd75e24dbcd96e1abd0302145598d90947435d10
Author: Radoslav Georgiev <rageorgiev@gmail.com>
Date:   Thu Dec 11 21:20:58 2025 +0200

    Add the initial set of POS catalog functionality (#62313)

    * Add the initial set of functionality, without ACP support

    * Remove wpfoai naming

    * Fix textdomains

    * Update the controller namespace

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Add tests

    * Update the APIController and test to use the container correctly.

    * Update @since tags

    * Fix incorrect file comment

    * PHPCS fixes

    * Fix a ton of linting incompatibilities.

    * Addressing since tags and trailing parameter commas.

    * Rename an old `oapfw` filte rname.

    * Addressing all PHPStan errors.

    * Fix a short comment.

    * Addressing wrong strings in tests.

    * Address JsonFileFeedTest comments.

    * Address ProductWalker and ApiController feedback.

    * Add failure handling during generation

    * Fix a spacing issue.

    * Address PHPStan errors individually rather than adjusting baseline.

    * Treat 0 memory limit as unlimited as well

    * Allow extensions to register integrations

    * Rename endpoint

    ---------

    Co-authored-by: github-actions <github-actions@github.com>

diff --git a/plugins/woocommerce/changelog/62313-pay-436-move-pos-catalog-functionality-into-woocommerce-core b/plugins/woocommerce/changelog/62313-pay-436-move-pos-catalog-functionality-into-woocommerce-core
new file mode 100644
index 0000000000..64f49b42aa
--- /dev/null
+++ b/plugins/woocommerce/changelog/62313-pay-436-move-pos-catalog-functionality-into-woocommerce-core
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a new controller for generating a product feed for the Woo mobile app.
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index b1825125db..a677085e3f 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -377,6 +377,7 @@ final class WooCommerce {
 		$container->get( Automattic\WooCommerce\Internal\Email\EmailStyleSync::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Fulfillments\FulfillmentsController::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Admin\Agentic\AgenticController::class )->register();
+		$container->get( Automattic\WooCommerce\Internal\ProductFeed\ProductFeed::class )->register();

 		// Classes inheriting from RestApiControllerBase.
 		$container->get( Automattic\WooCommerce\Internal\ReceiptRendering\ReceiptRenderingRestController::class )->register();
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index f078f14461..f6e5c7a10a 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -103709,4 +103709,3 @@ parameters:
 			identifier: return.type
 			count: 1
 			path: woocommerce.php
-
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Feed/FeedInterface.php b/plugins/woocommerce/src/Internal/ProductFeed/Feed/FeedInterface.php
new file mode 100644
index 0000000000..a363268f65
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Feed/FeedInterface.php
@@ -0,0 +1,54 @@
+<?php
+/**
+ * Feed Interface.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Feed;
+
+/**
+ * Feed Interface.
+ *
+ * @since 10.5.0
+ */
+interface FeedInterface {
+	/**
+	 * Start the feed.
+	 * This can create an empty file, eventually put something in it, or add a database entry.
+	 *
+	 * @return void
+	 */
+	public function start(): void;
+
+	/**
+	 * Add an entry to the feed.
+	 *
+	 * @param array $entry The entry to add.
+	 * @return void
+	 */
+	public function add_entry( array $entry ): void;
+
+	/**
+	 * End the feed.
+	 *
+	 * @return void
+	 */
+	public function end(): void;
+
+	/**
+	 * Get the file path of the feed.
+	 *
+	 * @return string|null The path to the feed file, null if not ready.
+	 */
+	public function get_file_path(): ?string;
+
+	/**
+	 * Get the URL of the feed file.
+	 *
+	 * @return string|null The URL of the feed file, null if not ready.
+	 */
+	public function get_file_url(): ?string;
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Feed/FeedValidatorInterface.php b/plugins/woocommerce/src/Internal/ProductFeed/Feed/FeedValidatorInterface.php
new file mode 100644
index 0000000000..14d3061b8b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Feed/FeedValidatorInterface.php
@@ -0,0 +1,26 @@
+<?php
+/**
+ * Feed Validator Interface.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Feed;
+
+/**
+ * Feed Validator Interface.
+ *
+ * @since 10.5.0
+ */
+interface FeedValidatorInterface {
+	/**
+	 * Validate a single entry.
+	 *
+	 * @param array       $row     The entry to validate.
+	 * @param \WC_Product $product The related product. Will be updated with validation status.
+	 * @return string[]            Validation issues.
+	 */
+	public function validate_entry( array $row, \WC_Product $product ): array;
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductLoader.php b/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductLoader.php
new file mode 100644
index 0000000000..a4dd8811b1
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductLoader.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * Product Loader class.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Feed;
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+/**
+ * Loader for products.
+ *
+ * @since 10.5.0
+ */
+class ProductLoader {
+	/**
+	 * Retrieves products from WooCommerce.
+	 *
+	 * @since 10.5.0
+	 *
+	 * @see wc_get_products()
+	 *
+	 * @param array $args The arguments to pass to wc_get_products().
+	 * @return array|\stdClass Number of pages and an array of product objects if
+	 *                         paginate is true, or just an array of values.
+	 */
+	public function get_products( array $args ) {
+		return wc_get_products( $args );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductMapperInterface.php b/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductMapperInterface.php
new file mode 100644
index 0000000000..8a07a1721b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductMapperInterface.php
@@ -0,0 +1,25 @@
+<?php
+/**
+ * Product Mapper Interface.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Feed;
+
+/**
+ * Product Mapper Interface.
+ *
+ * @since 10.5.0
+ */
+interface ProductMapperInterface {
+	/**
+	 * Map a product to a feed row.
+	 *
+	 * @param \WC_Product $product The product to map.
+	 * @return array The feed row.
+	 */
+	public function map_product( \WC_Product $product ): array;
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductWalker.php b/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductWalker.php
new file mode 100644
index 0000000000..cfc80bd6d4
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductWalker.php
@@ -0,0 +1,288 @@
+<?php
+/**
+ * Product Walker class.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Feed;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\IntegrationInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Utils\MemoryManager;
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+/**
+ * Walker for products.
+ *
+ * @since 10.5.0
+ */
+class ProductWalker {
+	/**
+	 * The product loader.
+	 *
+	 * @var ProductLoader
+	 */
+	private ProductLoader $product_loader;
+
+	/**
+	 * The product mapper.
+	 *
+	 * @var ProductMapperInterface
+	 */
+	private ProductMapperInterface $mapper;
+
+	/**
+	 * The feed.
+	 *
+	 * @var FeedInterface
+	 */
+	private FeedInterface $feed;
+
+	/**
+	 * The feed validator.
+	 *
+	 * @var FeedValidatorInterface
+	 */
+	private FeedValidatorInterface $validator;
+
+	/**
+	 * The memory manager.
+	 *
+	 * @var MemoryManager
+	 */
+	private MemoryManager $memory_manager;
+
+	/**
+	 * The number of products to iterate through per batch.
+	 *
+	 * @var int
+	 */
+	private int $per_page = 100;
+
+	/**
+	 * The time limit to extend the execution time limit per batch.
+	 *
+	 * @var int
+	 */
+	private int $time_limit = 0;
+
+	/**
+	 * The query arguments to apply to the product query.
+	 *
+	 * @var array
+	 */
+	private array $query_args;
+
+	/**
+	 * Class constructor.
+	 *
+	 * This class will not be available through DI. Instead, it needs to be instantiated directly.
+	 *
+	 * @param ProductMapperInterface $mapper The product mapper.
+	 * @param FeedValidatorInterface $validator The feed validator.
+	 * @param FeedInterface          $feed The feed.
+	 * @param ProductLoader          $product_loader The product loader.
+	 * @param MemoryManager          $memory_manager The memory manager.
+	 * @param array                  $query_args The query arguments.
+	 */
+	private function __construct(
+		ProductMapperInterface $mapper,
+		FeedValidatorInterface $validator,
+		FeedInterface $feed,
+		ProductLoader $product_loader,
+		MemoryManager $memory_manager,
+		array $query_args
+	) {
+		$this->mapper         = $mapper;
+		$this->validator      = $validator;
+		$this->feed           = $feed;
+		$this->product_loader = $product_loader;
+		$this->memory_manager = $memory_manager;
+		$this->query_args     = $query_args;
+	}
+
+	/**
+	 * Creates a new instance of the ProductWalker class based on an integration.
+	 *
+	 * The walker will mostly be set up based on the integration.
+	 * The feed is provided externally, as it might be based on the context (CLI, REST, Action Scheduler, etc.).
+	 *
+	 * @since 10.5.0
+	 *
+	 * @param IntegrationInterface $integration The integration.
+	 * @param FeedInterface        $feed        The feed.
+	 * @return self The ProductWalker instance.
+	 */
+	public static function from_integration(
+		IntegrationInterface $integration,
+		FeedInterface $feed
+	): self {
+		$query_args = array_merge(
+			array(
+				'status' => array( 'publish' ),
+				'return' => 'objects',
+			),
+			$integration->get_product_feed_query_args()
+		);
+
+		/**
+		 * Allows the base arguments for querying products for product feeds to be changed.
+		 *
+		 * Variable products are not included by default, as their variations will be included.
+		 *
+		 * @since 10.5.0
+		 *
+		 * @param array                $query_args The arguments to pass to wc_get_products().
+		 * @param IntegrationInterface $integration The integration that the query belongs to.
+		 * @return array
+		 */
+		$query_args = apply_filters(
+			'woocommerce_product_feed_args',
+			$query_args,
+			$integration
+		);
+
+		$instance = new self(
+			$integration->get_product_mapper(),
+			$integration->get_feed_validator(),
+			$feed,
+			wc_get_container()->get( ProductLoader::class ),
+			wc_get_container()->get( MemoryManager::class ),
+			$query_args
+		);
+
+		return $instance;
+	}
+
+	/**
+	 * Set the number of products to iterate through per batch.
+	 *
+	 * @since 10.5.0
+	 *
+	 * @param int $batch_size The number of products to iterate through per batch.
+	 * @return self
+	 */
+	public function set_batch_size( int $batch_size ): self {
+		if ( $batch_size < 1 ) {
+			$batch_size = 1;
+		}
+
+		$this->per_page = $batch_size;
+		return $this;
+	}
+
+	/**
+	 * Set the time limit to extend the execution time limit per batch.
+	 *
+	 * @since 10.5.0
+	 *
+	 * @param int $time_limit Time limit in seconds.
+	 * @return self
+	 */
+	public function add_time_limit( int $time_limit ): self {
+		if ( $time_limit < 0 ) {
+			$time_limit = 0;
+		}
+
+		$this->time_limit = $time_limit;
+		return $this;
+	}
+
+	/**
+	 * Walks through all products.
+	 *
+	 * @since 10.5.0
+	 *
+	 * @param callable $callback The callback to call after each batch of products is processed.
+	 * @return int The total number of products processed.
+	 */
+	public function walk( ?callable $callback = null ): int {
+		$progress = null;
+
+		// Instruct the feed to start.
+		$this->feed->start();
+
+		// Check how much memory is available at first.
+		$initial_available_memory = $this->memory_manager->get_available_memory();
+
+		do {
+			$result   = $this->iterate( $this->query_args, $progress ? $progress->processed_batches + 1 : 1, $this->per_page );
+			$iterated = count( $result->products );
+
+			// Only done when the progress is not set. Will be modified otherwise.
+			if ( is_null( $progress ) ) {
+				$progress = WalkerProgress::from_wc_get_products_result( $result );
+			}
+			$progress->processed_items += $iterated;
+			++$progress->processed_batches;
+
+			if ( is_callable( $callback ) && $iterated > 0 ) {
+				$callback( $progress );
+			}
+
+			if ( $this->time_limit > 0 ) {
+				set_time_limit( $this->time_limit );
+			}
+
+			// We don't want to use more than half of the available memory at the beginning of the script.
+			$current_memory = $this->memory_manager->get_available_memory();
+			if ( $initial_available_memory - $current_memory >= $initial_available_memory / 2 ) {
+				$this->memory_manager->flush_caches();
+			}
+		} while (
+			// If `wc_get_products()` returns less than the batch size, it was the last page.
+			$iterated === $this->per_page
+
+			// For the cases where the above is true, make sure that we do not exceed the total number of pages.
+			&& $progress->processed_batches < $progress->total_batch_count
+		);
+
+		// Instruct the feed to end.
+		$this->feed->end();
+
+		return $progress->processed_items;
+	}
+
+	/**
+	 * Iterates through a batch of products.
+	 *
+	 * @param array $args The arguments to pass to wc_get_products().
+	 * @param int   $page The page number to iterate through.
+	 * @param int   $limit The maximum number of products to iterate through.
+	 * @return \stdClass The result of the query with properties: products, total, max_num_pages.
+	 */
+	private function iterate( array $args = array(), int $page = 1, int $limit = 100 ): \stdClass {
+		/**
+		 * Result is always stdClass when paginate=true.
+		 *
+		 * @var \stdClass $result
+		 */
+		$result = $this->product_loader->get_products(
+			array_merge(
+				$args,
+				array(
+					'page'     => $page,
+					'limit'    => $limit,
+					'paginate' => true,
+				)
+			)
+		);
+
+		foreach ( $result->products as $product ) {
+			$mapped_data = $this->mapper->map_product( $product );
+
+			if ( ! empty( $this->validator->validate_entry( $mapped_data, $product ) ) ) {
+				continue;
+			}
+
+			$this->feed->add_entry( $mapped_data );
+		}
+
+		return $result;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Feed/WalkerProgress.php b/plugins/woocommerce/src/Internal/ProductFeed/Feed/WalkerProgress.php
new file mode 100644
index 0000000000..4955982b3b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Feed/WalkerProgress.php
@@ -0,0 +1,64 @@
+<?php
+/**
+ * Walker Progress class.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Feed;
+
+/**
+ * Simple class that tracks/indicates the progress of a walker.
+ *
+ * @since 10.5.0
+ */
+final class WalkerProgress {
+	/**
+	 * Total number of items to process.
+	 *
+	 * @var int
+	 */
+	public int $total_count;
+
+	/**
+	 * Total number of batches to process.
+	 *
+	 * @var int
+	 */
+	public int $total_batch_count;
+
+	/**
+	 * Number of items processed so far.
+	 *
+	 * @var int
+	 */
+	public int $processed_items = 0;
+
+	/**
+	 * Number of batches processed so far.
+	 *
+	 * @var int
+	 */
+	public int $processed_batches = 0;
+
+	/**
+	 * Creates a WalkerProgress instance from a WooCommerce products query result.
+	 *
+	 * @since 10.5.0
+	 *
+	 * @param \stdClass $result The result object from wc_get_products() with total and max_num_pages properties.
+	 * @return self
+	 */
+	public static function from_wc_get_products_result( \stdClass $result ): self {
+		$progress = new self();
+
+		$progress->total_count       = $result->total;
+		$progress->total_batch_count = $result->max_num_pages;
+		$progress->processed_items   = 0;
+		$progress->processed_batches = 0;
+
+		return $progress;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/IntegrationInterface.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/IntegrationInterface.php
new file mode 100644
index 0000000000..32e97d1978
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/IntegrationInterface.php
@@ -0,0 +1,90 @@
+<?php
+/**
+ * Interface that should be implemented by all provider integrations.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedValidatorInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductMapperInterface;
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+/**
+ * IntegrationInterface
+ *
+ * @since 10.5.0
+ */
+interface IntegrationInterface {
+	/**
+	 * Get the ID of the provider.
+	 *
+	 * @return string The ID of the provider.
+	 */
+	public function get_id(): string;
+
+	/**
+	 * Register hooks for the integration.
+	 *
+	 * @return void
+	 */
+	public function register_hooks(): void;
+
+	/**
+	 * Activate the integration.
+	 *
+	 * This method is called when the plugin is activated.
+	 * If there is ever a setting that controls active integrations,
+	 * this method might also be called when the integration is activated.
+	 *
+	 * @return void
+	 */
+	public function activate(): void;
+
+	/**
+	 * Deactivate the integration.
+	 *
+	 * This method is called when the plugin is deactivated.
+	 * If there is ever a setting that controls active integrations,
+	 * this method might also be called when the integration is deactivated.
+	 *
+	 * @return void
+	 */
+	public function deactivate(): void;
+
+	/**
+	 * Get the query arguments for the product feed.
+	 *
+	 * @see wc_get_products()
+	 * @return array The query arguments.
+	 */
+	public function get_product_feed_query_args(): array;
+
+	/**
+	 * Create a feed that is to be populated.
+	 *
+	 * @return FeedInterface The feed.
+	 */
+	public function create_feed(): FeedInterface;
+
+	/**
+	 * Get the product mapper for the provider.
+	 *
+	 * @return ProductMapperInterface The product mapper.
+	 */
+	public function get_product_mapper(): ProductMapperInterface;
+
+	/**
+	 * Get the feed validator for the provider.
+	 *
+	 * @return FeedValidatorInterface The feed validator.
+	 */
+	public function get_feed_validator(): FeedValidatorInterface;
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/IntegrationRegistry.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/IntegrationRegistry.php
new file mode 100644
index 0000000000..42f45491a6
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/IntegrationRegistry.php
@@ -0,0 +1,64 @@
+<?php
+/**
+ * Integration Registry class.
+ *
+ * Stores all provider integrations that are available.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations;
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+/**
+ * IntegrationRegistry
+ *
+ * @since 10.5.0
+ */
+class IntegrationRegistry {
+	/**
+	 * List of all available Integrations.
+	 *
+	 * @var array<string,IntegrationInterface>
+	 */
+	private array $integrations = array();
+
+	/**
+	 * Register an Integration.
+	 *
+	 * @since 10.5.0
+	 *
+	 * @param IntegrationInterface $integration The integration to register.
+	 */
+	public function register_integration( IntegrationInterface $integration ): void {
+		$this->integrations[ $integration->get_id() ] = $integration;
+	}
+
+	/**
+	 * Get an Integration by ID.
+	 *
+	 * @since 10.5.0
+	 *
+	 * @param string $id The ID of the Integration.
+	 * @return IntegrationInterface|null The Integration, or null if it is not registered.
+	 */
+	public function get_integration( string $id ): ?IntegrationInterface {
+		return $this->integrations[ $id ] ?? null;
+	}
+
+	/**
+	 * Get all registered integrations.
+	 *
+	 * @since 10.5.0
+	 *
+	 * @return array<string,IntegrationInterface>
+	 */
+	public function get_integrations(): array {
+		return $this->integrations;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ApiController.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ApiController.php
new file mode 100644
index 0000000000..fca3ab01ed
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ApiController.php
@@ -0,0 +1,139 @@
+<?php
+/**
+ * POS Catalog API Controller.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog;
+
+use Automattic\WooCommerce\Container;
+use WP_REST_Request;
+use WP_REST_Response;
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+/**
+ * POS Catalog API Controller.
+ *
+ * @since 10.5.0
+ */
+class ApiController {
+	const ROUTE_NAMESPACE = 'wc/pos/v1/catalog';
+
+	/**
+	 * Container instance.
+	 *
+	 * @var Container
+	 */
+	private $container;
+
+	/**
+	 * Dependency injector.
+	 *
+	 * @param Container $container The container instance. Everything else will be dynamic.
+	 * @internal
+	 */
+	final public function init( Container $container ): void {
+		$this->container = $container;
+	}
+
+	/**
+	 * Register the routes for the API controller.
+	 */
+	public function register_routes(): void {
+		register_rest_route(
+			self::ROUTE_NAMESPACE,
+			'/create',
+			array(
+				'methods'             => 'POST',
+				'callback'            => array( $this, 'generate_feed' ),
+				'permission_callback' => array( $this, 'is_authorized' ),
+				'args'                => array(
+					'force'             => array(
+						'type'        => 'boolean',
+						'default'     => false,
+						'description' => 'Force regeneration of the feed. NOOP if generation is in progress.',
+					),
+					'_product_fields'   => array(
+						'type'        => 'string',
+						'description' => 'Comma-separated list of fields to include for non-variable products.',
+						'required'    => false,
+					),
+					'_variation_fields' => array(
+						'type'        => 'string',
+						'description' => 'Comma-separated list of fields to include for variations.',
+						'required'    => false,
+					),
+				),
+			)
+		);
+	}
+
+	/**
+	 * Checks if the current user has the necessary permissions to access the API.
+	 *
+	 * @return bool True if the user has the necessary permissions, false otherwise.
+	 */
+	public function is_authorized() {
+		return is_user_logged_in() && (
+			current_user_can( 'manage_woocommerce' ) || current_user_can( 'manage_options' )
+		);
+	}
+
+	/**
+	 * Starts generating a feed.
+	 *
+	 * @param WP_REST_Request<array<string, mixed>> $request The request object.
+	 * @return WP_REST_Response The response object.
+	 */
+	public function generate_feed( WP_REST_Request $request ): WP_REST_Response { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
+		$generator = $this->container->get( AsyncGenerator::class );
+		try {
+			$params = array();
+			if ( null !== $request['_product_fields'] ) {
+				$params['_product_fields'] = $request['_product_fields'];
+			}
+			if ( null !== $request['_variation_fields'] ) {
+				$params['_variation_fields'] = $request['_variation_fields'];
+			}
+
+			$response = $request->get_param( 'force' )
+				? $generator->force_regeneration( $params )
+				: $generator->get_status( $params );
+
+			// Use the right datetime format.
+			if ( isset( $response['scheduled_at'] ) ) {
+				$response['scheduled_at'] = wc_rest_prepare_date_response( $response['scheduled_at'] );
+			}
+			if ( isset( $response['completed_at'] ) ) {
+				$response['completed_at'] = wc_rest_prepare_date_response( $response['completed_at'] );
+			}
+
+			// Remove sensitive data from the response.
+			if ( isset( $response['action_id'] ) ) {
+				unset( $response['action_id'] );
+			}
+			if ( isset( $response['path'] ) ) {
+				unset( $response['path'] );
+			}
+		} catch ( \Exception $e ) {
+			wc_get_logger()->error(
+				'Feed generation failed',
+				array( 'error' => $e->getMessage() )
+			);
+			return new WP_REST_Response(
+				array(
+					'success' => false,
+					'message' => __( 'An error occurred while generating the feed.', 'woocommerce' ),
+				),
+				500
+			);
+		}
+		return new WP_REST_Response( $response );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGenerator.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGenerator.php
new file mode 100644
index 0000000000..553342fa1e
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGenerator.php
@@ -0,0 +1,374 @@
+<?php
+/**
+ *  Async Generator class.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog;
+
+use ActionScheduler_AsyncRequest_QueueRunner;
+use ActionScheduler_Store;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductWalker;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\WalkerProgress;
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+/**
+ * Async Generator for feeds.
+ *
+ * @since 10.5.0
+ */
+class AsyncGenerator {
+	/**
+	 * The Action Scheduler action hook for the feed generation.
+	 *
+	 * @var string
+	 */
+	const FEED_GENERATION_ACTION = 'woocommerce_product_feed_generation';
+
+	/**
+	 * The Action Scheduler action hook for the feed deletion.
+	 *
+	 * @var string
+	 */
+	const FEED_DELETION_ACTION = 'woocommerce_product_feed_deletion';
+
+	/**
+	 * Feed expiry time, once completed.
+	 * If the feed is not downloaded within this timeframe, a new one will need to be generated.
+	 *
+	 * @var int
+	 */
+	const FEED_EXPIRY = 24 * HOUR_IN_SECONDS;
+
+	/**
+	 * Possible states of generation.
+	 */
+	const STATE_SCHEDULED   = 'scheduled';
+	const STATE_IN_PROGRESS = 'in_progress';
+	const STATE_COMPLETED   = 'completed';
+	const STATE_FAILED      = 'failed';
+
+	/**
+	 * Integration instance.
+	 *
+	 * @var POSIntegration
+	 */
+	private $integration;
+
+	/**
+	 * Dependency injector.
+	 *
+	 * @param POSIntegration $integration The integration instance.
+	 * @internal
+	 */
+	final public function init( POSIntegration $integration ): void {
+		$this->integration = $integration;
+	}
+
+	/**
+	 * Register hooks for the async generator.
+	 *
+	 * @since 10.5.0
+	 *
+	 * @return void
+	 */
+	public function register_hooks(): void {
+		add_action( self::FEED_GENERATION_ACTION, array( $this, 'feed_generation_action' ) );
+		add_action( self::FEED_DELETION_ACTION, array( $this, 'feed_deletion_action' ), 10, 2 );
+	}
+
+	/**
+	 * Returns the current feed generation status.
+	 * Initiates one if not already running.
+	 *
+	 * @since 10.5.0
+	 *
+	 * @param array|null $args The arguments to pass to the action.
+	 * @return array           The feed generation status.
+	 */
+	public function get_status( ?array $args = null ): array {
+		// Determine the option key based on the integration ID and arguments.
+		$option_key = $this->get_option_key( $args );
+		$status     = get_option( $option_key );
+
+		// For existing jobs, make sure that everything in the status makes sense.
+		if ( is_array( $status ) && ! $this->validate_status( $status ) ) {
+			$status = false;
+		}
+
+		// If the status is an array, it means that there is nothing to schedule in this method.
+		if ( is_array( $status ) ) {
+			return $status;
+		}
+
+		// Clear all previous actions to avoid race conditions.
+		as_unschedule_all_actions( self::FEED_GENERATION_ACTION, array( $option_key ), 'woo-product-feed' ); // @phpstan-ignore function.notFound
+
+		$status = array(
+			'scheduled_at' => time(),
+			'completed_at' => null,
+			'state'        => self::STATE_SCHEDULED,
+			'progress'     => 0,
+			'processed'    => 0,
+			'total'        => -1,
+			'args'         => $args ?? array(),
+		);
+
+		update_option(
+			$option_key,
+			$status
+		);
+
+		// Start an immediate async action to generate the feed.
+		// @phpstan-ignore-next-line function.notFound -- Action Scheduler.
+		as_enqueue_async_action(
+			self::FEED_GENERATION_ACTION,
+			array( $option_key ),
+			'woo-product-feed',
+			true,
+			1
+		);
+
+		// Manually force an async request to be dispatched to process the action immediately.
+		if ( class_exists( ActionScheduler_AsyncRequest_QueueRunner::class ) && class_exists( ActionScheduler_Store::class ) ) {
+			$store         = ActionScheduler_Store::instance();
+			$async_request = new ActionScheduler_AsyncRequest_QueueRunner( $store );
+			$async_request->dispatch();
+		}
+
+		return $status;
+	}
+
+	/**
+	 * Action scheduler callback for the feed generation.
+	 *
+	 * @since 10.5.0
+	 *
+	 * @param string $option_key The option key for the feed generation status.
+	 * @return void
+	 */
+	public function feed_generation_action( string $option_key ) {
+		$status = get_option( $option_key );
+
+		if ( ! is_array( $status ) || ! isset( $status['state'] ) || self::STATE_SCHEDULED !== $status['state'] ) {
+			wc_get_logger()->error( 'Invalid feed generation status', array( 'status' => $status ) );
+			return;
+		}
+
+		$status['state'] = self::STATE_IN_PROGRESS;
+		update_option( $option_key, $status );
+
+		try {
+			$feed   = $this->integration->create_feed();
+			$walker = ProductWalker::from_integration( $this->integration, $feed );
+
+			// Add dynamic args to the mapper.
+			$args = $status['args'] ?? array();
+			if (
+				isset( $args['_product_fields'] )
+				&& is_string( $args['_product_fields'] ) &&
+				! empty( $args['_product_fields'] )
+			) {
+				$this->integration->get_product_mapper()->set_fields( $args['_product_fields'] );
+			}
+			if (
+				isset( $args['_variation_fields'] )
+				&& is_string( $args['_variation_fields'] ) &&
+				! empty( $args['_variation_fields'] )
+			) {
+				$this->integration->get_product_mapper()->set_variation_fields( $args['_variation_fields'] );
+			}
+
+			$walker->walk(
+				function ( WalkerProgress $progress ) use ( &$status, $option_key ) {
+					$status = $this->update_feed_progress( $status, $progress );
+					update_option( $option_key, $status );
+				}
+			);
+
+			// Store the final details.
+			$status['state']        = self::STATE_COMPLETED;
+			$status['url']          = $feed->get_file_url();
+			$status['path']         = $feed->get_file_path();
+			$status['completed_at'] = time();
+			update_option( $option_key, $status );
+
+			// Schedule another action to delete the file after the expiry time.
+			// @phpstan-ignore-next-line function.notFound -- Action Scheduler.
+			as_schedule_single_action(
+				time() + self::FEED_EXPIRY,
+				self::FEED_DELETION_ACTION,
+				array(
+					$option_key,
+					$feed->get_file_path(),
+				),
+				'woo-product-feed',
+				true
+			);
+		} catch ( \Throwable $e ) {
+			wc_get_logger()->error(
+				'Feed generation failed',
+				array(
+					'error'      => $e->getMessage(),
+					'option_key' => $option_key,
+				)
+			);
+
+			$status['state']     = self::STATE_FAILED;
+			$status['error']     = $e->getMessage();
+			$status['failed_at'] = time();
+			update_option( $option_key, $status );
+		}
+	}
+
+	/**
+	 * Forces a regeneration of the feed.
+	 *
+	 * @since 10.5.0
+	 *
+	 * @param array|null $args The arguments to pass to the action.
+	 * @return array The feed generation status.
+	 * @throws \Exception When there is a reason why the regeneration cannot be forced.
+	 */
+	public function force_regeneration( ?array $args = null ): array {
+		$option_key = $this->get_option_key( $args );
+		$status     = get_option( $option_key );
+
+		// If there is no option, there is nothing to force. If the option is invalid, we can restart.
+		if ( ! is_array( $status ) || ! $this->validate_status( $status ) ) {
+			return $this->get_status( $args );
+		}
+
+		switch ( $status['state'] ?? '' ) {
+			case self::STATE_SCHEDULED:
+				// If generation is scheduled, we can just let it be and return the current status.
+				// It should start shortly.
+				return $status;
+
+			case self::STATE_IN_PROGRESS:
+				throw new \Exception( 'Feed generation is already in progress and cannot be stopped.' );
+
+			case self::STATE_COMPLETED:
+				// Delete the existing file, clear the option and let generation start again.
+				wp_delete_file( (string) $status['path'] );
+				delete_option( $option_key );
+				return $this->get_status( $args );
+
+			case self::STATE_FAILED:
+				// Clear the failed status and restart generation.
+				delete_option( $option_key );
+				return $this->get_status( $args );
+
+			default:
+				throw new \Exception( 'Unknown feed generation state.' );
+		}
+	}
+
+	/**
+	 * Action scheduler callback for the feed deletion after expiry.
+	 *
+	 * @since 10.5.0
+	 *
+	 * @param string $option_key The option key for the feed generation status.
+	 * @param string $path       The path to the feed file.
+	 * @return void
+	 */
+	public function feed_deletion_action( string $option_key, string $path ) {
+		delete_option( $option_key );
+		wp_delete_file( $path );
+	}
+
+	/**
+	 * Returns the option key for the feed generation status.
+	 *
+	 * @param array|null $args The arguments to pass to the action.
+	 * @return string          The option key.
+	 */
+	private function get_option_key( ?array $args = null ): string {
+		$normalized_args = $args ?? array();
+		if ( ! empty( $normalized_args ) ) {
+			ksort( $normalized_args );
+		}
+
+		return 'feed_status_' . md5(
+			// WPCS dislikes serialize for security reasons, but it will be hashed immediately.
+			// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
+			serialize(
+				array(
+					'integration' => $this->integration->get_id(),
+					'args'        => $normalized_args,
+				)
+			)
+		);
+	}
+
+	/**
+	 * Updates the feed progress while the feed is being generated.
+	 *
+	 * @param array          $status   The last previously known status.
+	 * @param WalkerProgress $progress The progress of the walker.
+	 * @return array                   Updated status of the feed generation.
+	 */
+	private function update_feed_progress( array $status, WalkerProgress $progress ): array {
+		$status['progress']  = $progress->total_count > 0
+			? round( ( $progress->processed_items / $progress->total_count ) * 100, 2 )
+			: 0;
+		$status['processed'] = $progress->processed_items;
+		$status['total']     = $progress->total_count;
+		return $status;
+	}
+
+	/**
+	 * Validates the status of the feed generation.
+	 *
+	 * Makes sure that the file exists for completed jobs,
+	 * that scheduled jobs are not stuck, etc.
+	 *
+	 * @param array $status The status of the feed generation.
+	 * @return bool         True if the status is valid, false otherwise.
+	 */
+	private function validate_status( array $status ): bool {
+		// Validate the state.
+		/**
+		 * For completed jobs, make sure the file still exists. Regenerate otherwise.
+		 *
+		 * The file should typically get deleted at the same time as the status is cleared.
+		 * However, something else could cause the file to disappear in the meantime (ex. manual delete).
+		 */
+		if ( self::STATE_COMPLETED === $status['state'] && ! file_exists( $status['path'] ) ) {
+			return false;
+		}
+
+		/**
+		 * If the job has been scheduled more than 10 minutes ago but has not
+		 * transitioned to IN_PROGRESS yet, ActionScheduler is typically stuck.
+		 */
+
+		/**
+		 * Allows the timeout for a feed to remain in `scheduled` state to be changed.
+		 *
+		 * @param int $stuck_time The stuck time in seconds.
+		 * @return int The stuck time in seconds.
+		 * @since 10.5.0
+		 */
+		$scheduled_timeout = apply_filters( 'woocommerce_product_feed_scheduled_timeout', 10 * MINUTE_IN_SECONDS );
+		if (
+			self::STATE_SCHEDULED === $status['state']
+			&& (
+				! isset( $status['scheduled_at'] )
+				|| time() - $status['scheduled_at'] > $scheduled_timeout
+			)
+		) {
+			return false;
+		}
+
+		// All good.
+		return true;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/FeedValidator.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/FeedValidator.php
new file mode 100644
index 0000000000..da116097ae
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/FeedValidator.php
@@ -0,0 +1,34 @@
+<?php
+/**
+ *  Feed Validator class.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedValidatorInterface;
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+/**
+ * Simple field validator for the POS catalog.
+ *
+ * @since 10.5.0
+ */
+final class FeedValidator implements FeedValidatorInterface {
+	/**
+	 * Validate single feed row using schema.
+	 *
+	 * @param array       $entry   Product data row to validate.
+	 * @param \WC_Product $product The related product. Will be updated with validation status.
+	 * @return array Array of validation issues.
+	 */
+	public function validate_entry( array $entry, \WC_Product $product ): array { //phpcs:ignore VariableAnalysis
+		return array();
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/POSIntegration.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/POSIntegration.php
new file mode 100644
index 0000000000..00fdda9f97
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/POSIntegration.php
@@ -0,0 +1,113 @@
+<?php
+/**
+ * POS Catalog Integration class.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog;
+
+use Automattic\WooCommerce\Container;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedValidatorInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\IntegrationInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Storage\JsonFileFeed;
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+/**
+ * POS Catalog Integration
+ *
+ * @since 10.5.0
+ */
+class POSIntegration implements IntegrationInterface {
+	/**
+	 * Container instance.
+	 *
+	 * @var Container
+	 */
+	private Container $container;
+
+	/**
+	 * Dependency injector.
+	 *
+	 * @param Container $container Dependency container.
+	 * @internal
+	 */
+	final public function init( Container $container ): void {
+		$this->container = $container;
+	}
+
+	/**
+	 * {@inheritdoc}
+	 */
+	public function get_id(): string {
+		return 'pos';
+	}
+
+	/**
+	 * {@inheritdoc}
+	 */
+	public function get_product_feed_query_args(): array {
+		return array(
+			'type' => array( 'simple', 'variable', 'variation' ),
+		);
+	}
+
+	/**
+	 * {@inheritdoc}
+	 */
+	public function register_hooks(): void {
+		add_action( 'rest_api_init', array( $this, 'rest_api_init' ) );
+		$this->container->get( AsyncGenerator::class )->register_hooks();
+	}
+
+	/**
+	 * Initialize the REST API.
+	 *
+	 * @return void
+	 */
+	public function rest_api_init(): void {
+		// Only load the controller when necessary.
+		$this->container->get( ApiController::class )->register_routes();
+	}
+
+	/**
+	 * {@inheritdoc}
+	 */
+	public function activate(): void {
+		// At the moment, there are no activation steps for the POS catalog.
+	}
+
+	/**
+	 * {@inheritdoc}
+	 */
+	public function deactivate(): void {
+		// At the moment, there are no deactivation steps for the POS catalog.
+	}
+
+	/**
+	 * {@inheritdoc}
+	 */
+	public function create_feed(): FeedInterface {
+		return new JsonFileFeed( 'pos-catalog-feed' );
+	}
+
+	/**
+	 * {@inheritdoc}
+	 */
+	public function get_product_mapper(): ProductMapper {
+		return $this->container->get( ProductMapper::class );
+	}
+
+	/**
+	 * {@inheritdoc}
+	 */
+	public function get_feed_validator(): FeedValidatorInterface {
+		return $this->container->get( FeedValidator::class );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ProductMapper.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ProductMapper.php
new file mode 100644
index 0000000000..52a706750e
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ProductMapper.php
@@ -0,0 +1,200 @@
+<?php
+/**
+ * ProductMapper class.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductMapperInterface;
+use WC_Product;
+use WC_REST_Products_Controller;
+use WC_REST_Product_Variations_Controller;
+use WP_REST_Request;
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+/**
+ * Product Mapper for the POS catalog.
+ *
+ * Uses WooCommerce REST API controllers to map product data.
+ *
+ * @since 10.5.0
+ */
+class ProductMapper implements ProductMapperInterface {
+	/**
+	 * Fields to include in the product mapping.
+	 *
+	 * @var string|null Fields to include in the product mapping.
+	 */
+	private ?string $fields = null;
+
+	/**
+	 * Fields to include in the variation mapping.
+	 *
+	 * @var string|null Fields to include in the variation mapping.
+	 */
+	private ?string $variation_fields = null;
+
+	/**
+	 * REST controller instance for products.
+	 *
+	 * @var WC_REST_Products_Controller|null
+	 */
+	private ?WC_REST_Products_Controller $products_controller = null;
+
+	/**
+	 * REST controller instance for variations.
+	 *
+	 * @var WC_REST_Product_Variations_Controller|null
+	 */
+	private ?WC_REST_Product_Variations_Controller $variations_controller = null;
+
+	/**
+	 * Cached REST request instance for products.
+	 *
+	 * @var WP_REST_Request<array<string, mixed>>|null
+	 */
+	private ?WP_REST_Request $products_request = null;
+
+	/**
+	 * Cached REST request instance for variations.
+	 *
+	 * @var WP_REST_Request<array<string, mixed>>|null
+	 */
+	private ?WP_REST_Request $variations_request = null;
+
+	/**
+	 * Initialize the mapper.
+	 *
+	 * @internal
+	 * @return void
+	 */
+	final public function init(): void {
+		$this->products_controller   = new WC_REST_Products_Controller();
+		$this->variations_controller = new WC_REST_Product_Variations_Controller();
+	}
+
+	/**
+	 * Set fields to include in the product mapping.
+	 *
+	 * @since 10.5.0
+	 *
+	 * @param string|null $fields Fields to include in the product mapping.
+	 * @return void
+	 */
+	public function set_fields( ?string $fields = null ): void {
+		$this->fields           = $fields;
+		$this->products_request = null; // Invalidate the cached request.
+	}
+
+	/**
+	 * Set fields to include in the variation mapping.
+	 *
+	 * @since 10.5.0
+	 *
+	 * @param string|null $fields Fields to include in the variation mapping.
+	 * @return void
+	 */
+	public function set_variation_fields( ?string $fields = null ): void {
+		$this->variation_fields   = $fields;
+		$this->variations_request = null; // Invalidate the cached request.
+	}
+
+	/**
+	 * Map WooCommerce product to catalog row
+	 *
+	 * @since 10.5.0
+	 *
+	 * @param WC_Product $product Product to map.
+	 * @return array Mapped product data array.
+	 * @throws \RuntimeException If the controller is not initialized.
+	 */
+	public function map_product( WC_Product $product ): array {
+		$is_variation = $product->is_type( 'variation' );
+		$controller   = $is_variation
+			? $this->variations_controller
+			: $this->products_controller;
+
+		// This should never be the case, as the class should be loaded through DI.
+		if ( null === $controller ) {
+			throw new \RuntimeException( 'ProductMapper::init() must be called before map_product().' );
+		}
+
+		$request  = $is_variation ? $this->get_variations_request() : $this->get_products_request();
+		$response = $controller->prepare_object_for_response( $product, $request );
+
+		// Apply _fields filtering (normally done by REST server dispatch).
+		$fields = $is_variation ? $this->variation_fields : $this->fields;
+		if ( null !== $fields ) {
+			$response = rest_filter_response_fields( $response, rest_get_server(), $request );
+		}
+
+		$row = array(
+			'type' => $product->get_type(),
+			'data' => $response->get_data(),
+		);
+
+		/**
+		 * Filter mapped catalog product data.
+		 *
+		 * @since 10.5.0
+		 * @param array      $row     Mapped product data.
+		 * @param WC_Product $product Product object.
+		 */
+		return apply_filters( 'woocommerce_pos_catalog_map_product', $row, $product );
+	}
+
+	/**
+	 * Get the REST request instance for products.
+	 *
+	 * @return WP_REST_Request<array<string, mixed>>
+	 */
+	protected function get_products_request(): WP_REST_Request {
+		if ( null === $this->products_request ) {
+			/**
+			 * Type hint for PHPStan generics.
+			 *
+			 * @var WP_REST_Request<array<string, mixed>> $request
+			 * */
+			$request                = new WP_REST_Request( 'GET' );
+			$this->products_request = $request;
+			$this->products_request->set_param( 'context', 'view' );
+
+			if ( null !== $this->fields ) {
+				$this->products_request->set_param( '_fields', $this->fields );
+			}
+		}
+
+		return $this->products_request;
+	}
+
+	/**
+	 * Get the REST request instance for variations.
+	 *
+	 * @return WP_REST_Request<array<string, mixed>>
+	 */
+	protected function get_variations_request(): WP_REST_Request {
+		if ( null === $this->variations_request ) {
+			/**
+			 * Type hint for PHPStan generics.
+			 *
+			 * @var WP_REST_Request<array<string, mixed>> $request
+			 */
+			$request                  = new WP_REST_Request( 'GET' );
+			$this->variations_request = $request;
+			$this->variations_request->set_param( 'context', 'view' );
+
+			if ( null !== $this->variation_fields ) {
+				$this->variations_request->set_param( '_fields', $this->variation_fields );
+			}
+		}
+
+		return $this->variations_request;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/ProductFeed.php b/plugins/woocommerce/src/Internal/ProductFeed/ProductFeed.php
new file mode 100644
index 0000000000..8b7e8ee09a
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/ProductFeed.php
@@ -0,0 +1,93 @@
+<?php
+/**
+ *  Plugin class.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\IntegrationInterface;
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\IntegrationRegistry;
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog\POSIntegration;
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+/**
+ * Main Product Feed class.
+ *
+ * @since 10.5.0
+ */
+final class ProductFeed implements RegisterHooksInterface {
+	/**
+	 * Integration registry.
+	 *
+	 * @var IntegrationRegistry
+	 */
+	private IntegrationRegistry $integration_registry;
+
+	/**
+	 * Dependency injector.
+	 *
+	 * @param IntegrationRegistry $integration_registry The integration registry.
+	 * @param POSIntegration      $pos_integration The POS integration.
+	 * @internal
+	 */
+	public function init( // phpcs:ignore WooCommerce.Functions.InternalInjectionMethod.MissingFinal
+		IntegrationRegistry $integration_registry,
+		POSIntegration $pos_integration
+	): void {
+		$this->integration_registry = $integration_registry;
+		$this->integration_registry->register_integration( $pos_integration );
+	}
+
+	/**
+	 * Allows extensions to register integrations.
+	 *
+	 * @since 10.5.0
+	 * @param IntegrationInterface $integration The integration to register.
+	 * @return void
+	 */
+	public function register_integration( IntegrationInterface $integration ): void {
+		$this->integration_registry->register_integration( $integration );
+	}
+
+	/**
+	 * Initialize plugin components
+	 *
+	 * @since 10.5.0
+	 */
+	public function register(): void {
+		// Let all integrations register their hooks.
+		foreach ( $this->integration_registry->get_integrations() as $integration ) {
+			$integration->register_hooks();
+		}
+	}
+
+	/**
+	 * Plugin activation
+	 *
+	 * @since 10.5.0
+	 */
+	public function activate(): void {
+		foreach ( $this->integration_registry->get_integrations() as $integration ) {
+			$integration->activate();
+		}
+	}
+
+	/**
+	 * Plugin deactivation
+	 *
+	 * @since 10.5.0
+	 */
+	public function deactivate(): void {
+		foreach ( $this->integration_registry->get_integrations() as $integration ) {
+			$integration->deactivate();
+		}
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Storage/JsonFileFeed.php b/plugins/woocommerce/src/Internal/ProductFeed/Storage/JsonFileFeed.php
new file mode 100644
index 0000000000..0b418d09b2
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Storage/JsonFileFeed.php
@@ -0,0 +1,289 @@
+<?php
+/**
+ * JSON File Feed class.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Storage;
+
+use Automattic\WooCommerce\Internal\Utilities\FilesystemUtil;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedInterface;
+use Exception;
+
+// This file works directly with local files. That's fine.
+// phpcs:disable WordPress.WP.AlternativeFunctions
+
+/**
+ * File-backed JSON feed storage.
+ *
+ * This class writes JSON directly to a file, entry by entry, without keeping everything in memory.
+ *
+ * @since 10.5.0
+ */
+class JsonFileFeed implements FeedInterface {
+	public const UPLOAD_DIR = 'product-feeds';
+
+	/**
+	 * Indicates if there are previous entries in the feed.
+	 *
+	 * @var bool
+	 */
+	private $has_entries = false;
+
+	/**
+	 * The base name of the feed file.
+	 *
+	 * @var string
+	 */
+	private $base_name;
+
+	/**
+	 * The name of the feed file, no directory.
+	 *
+	 * @var string
+	 */
+	private $file_name;
+
+	/**
+	 * The path to the feed file.
+	 *
+	 * @var string
+	 */
+	private $file_path;
+
+	/**
+	 * The file handle.
+	 *
+	 * @var resource|false|null
+	 */
+	private $file_handle = null;
+
+	/**
+	 * Indicates if the feed file has been completed.
+	 *
+	 * @var bool
+	 */
+	private $file_completed = false;
+
+	/**
+	 * The URL of the feed file.
+	 *
+	 * @var string|null
+	 */
+	private $file_url = null;
+
+	/**
+	 * Indicates if the feed file is in a temp directory.
+	 *
+	 * @var bool
+	 */
+	private $is_temp_filepath = false;
+
+	/**
+	 * Constructor.
+	 *
+	 * @param string $base_name The base name of the feed file.
+	 */
+	public function __construct( string $base_name ) {
+		$this->base_name = $base_name;
+	}
+
+	/**
+	 * Start the feed.
+	 *
+	 * @return void
+	 * @throws Exception If the feed directory cannot be created.
+	 */
+	public function start(): void {
+		/**
+		 * Allows the current time to be overridden before a feed is stored.
+		 *
+		 * @param int           $time The current time.
+		 * @param FeedInterface $feed The feed instance.
+		 * @return int The current time.
+		 * @since 10.5.0
+		 */
+		$current_time    = apply_filters( 'woocommerce_product_feed_time', time(), $this );
+		$hash_data       = $this->base_name . gmdate( 'r', $current_time );
+		$this->file_name = sprintf(
+			'%s-%s-%s.json',
+			$this->base_name,
+			gmdate( 'Y-m-d', $current_time ),
+			wp_hash( $hash_data )
+		);
+
+		// Start by trying to use a temp directory to generate the feed.
+		$this->file_path   = get_temp_dir() . DIRECTORY_SEPARATOR . $this->file_name;
+		$this->file_handle = fopen( $this->file_path, 'w' );
+		if ( false === $this->file_handle ) {
+			// Fall back to immediately using the upload directory for generation.
+			$upload_dir        = $this->get_upload_dir();
+			$this->file_path   = $upload_dir['path'] . $this->file_name;
+			$this->file_handle = fopen( $this->file_path, 'w' );
+		} else {
+			$this->is_temp_filepath = true;
+		}
+
+		if ( false === $this->file_handle ) {
+			throw new Exception(
+				esc_html(
+					sprintf(
+						/* translators: %s: directory path */
+						__( 'Unable to open feed file for writing: %s', 'woocommerce' ),
+						$this->file_path
+					)
+				)
+			);
+		}
+
+		// Open the array.
+		fwrite( $this->file_handle, '[' );
+	}
+
+	/**
+	 * Add an entry to the feed.
+	 *
+	 * @param array $entry The entry to add.
+	 * @return void
+	 */
+	public function add_entry( array $entry ): void {
+		if ( ! is_resource( $this->file_handle ) ) {
+			return;
+		}
+
+		if ( ! $this->has_entries ) {
+			$this->has_entries = true;
+		} else {
+			fwrite( $this->file_handle, ',' );
+		}
+
+		$json = wp_json_encode( $entry );
+		if ( false !== $json ) {
+			fwrite( $this->file_handle, $json );
+		}
+	}
+
+	/**
+	 * End the feed.
+	 *
+	 * @return void
+	 */
+	public function end(): void {
+		if ( ! is_resource( $this->file_handle ) ) {
+			return;
+		}
+
+		// Close the array and the file.
+		fwrite( $this->file_handle, ']' );
+		fclose( $this->file_handle );
+
+		// Indicate that we have a complete file.
+		$this->file_completed = true;
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	public function get_file_path(): ?string {
+		if ( ! $this->file_completed ) {
+			return null;
+		}
+
+		return $this->file_path;
+	}
+
+	/**
+	 * {@inheritDoc}
+	 *
+	 * @throws Exception If the feed file cannot be moved to the upload directory.
+	 */
+	public function get_file_url(): ?string {
+		if ( ! $this->file_completed ) {
+			return null;
+		}
+
+		$upload_dir = $this->get_upload_dir();
+
+		// Move the file to the upload directory if it is in temp.
+		if ( $this->is_temp_filepath ) {
+			$tmp_path        = $this->file_path;
+			$this->file_path = $upload_dir['path'] . $this->file_name;
+			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+			if ( ! @copy( $tmp_path, $this->file_path ) ) {
+				$error         = error_get_last();
+				$error_message = is_array( $error ) ? $error['message'] : 'Unknown error';
+				throw new Exception(
+					esc_html(
+						sprintf(
+							/* translators: %1$s: file path, %2$s: error message */
+							__( 'Unable to move feed file %1$s to upload directory: %2$s', 'woocommerce' ),
+							$this->file_path,
+							$error_message
+						)
+					)
+				);
+			}
+
+			unlink( $tmp_path );
+
+			$this->is_temp_filepath = false;
+		}
+
+		// Generate the URL.
+		$this->file_url = $upload_dir['url'] . $this->file_name;
+
+		return $this->file_url;
+	}
+
+	/**
+	 * Get the upload directory for the feed.
+	 *
+	 * @return array {
+	 *     The upload directory for the feed. Both fields end with the right trailing slash.
+	 *
+	 *     @type string $path The path to the upload directory.
+	 *     @type string $url The URL to the upload directory.
+	 * }
+	 * @throws Exception If the upload directory cannot be created.
+	 */
+	private function get_upload_dir(): array {
+		// Only generate everything once.
+		static $prepared;
+		if ( isset( $prepared ) ) {
+			return $prepared;
+		}
+
+		$upload_dir     = wp_upload_dir( null, true );
+		$directory_path = $upload_dir['basedir'] . DIRECTORY_SEPARATOR . self::UPLOAD_DIR . DIRECTORY_SEPARATOR;
+
+		// Try to create the directory if it does not exist.
+		if ( ! is_dir( $directory_path ) ) {
+			FilesystemUtil::mkdir_p_not_indexable( $directory_path );
+		}
+
+		// `mkdir_p_not_indexable()` returns `void`, we have to check again.
+		if ( ! is_dir( $directory_path ) ) {
+			throw new Exception(
+				esc_html(
+					sprintf(
+						/* translators: %s: directory path */
+						__( 'Unable to create feed directory: %s', 'woocommerce' ),
+						$directory_path
+					)
+				)
+			);
+		}
+
+		$directory_url = $upload_dir['baseurl'] . '/' . self::UPLOAD_DIR . '/';
+
+		// Follow the format, returned by `wp_upload_dir()`.
+		$prepared = array(
+			'path' => $directory_path,
+			'url'  => $directory_url,
+		);
+		return $prepared;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Utils/MemoryManager.php b/plugins/woocommerce/src/Internal/ProductFeed/Utils/MemoryManager.php
new file mode 100644
index 0000000000..f311fb930d
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Utils/MemoryManager.php
@@ -0,0 +1,110 @@
+<?php
+/**
+ * Memory Manager class.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Utils;
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+/**
+ * Helper class for managing memory.
+ *
+ * @since 10.5.0
+ */
+class MemoryManager {
+	/**
+	 * Get available memory as a percentage of the total memory limit.
+	 *
+	 * @since 10.5.0
+	 *
+	 * @return int Available memory as a percentage of the total memory limit.
+	 */
+	public function get_available_memory(): int {
+		$memory_limit = wp_convert_hr_to_bytes( ini_get( 'memory_limit' ) );
+		if ( 0 >= $memory_limit ) {
+			// Some systems have "unlimited" memory.
+			// We should treat that as if there is none left.
+			return 0;
+		}
+		return (int) round( 100 - ( memory_get_usage( true ) / $memory_limit ) * 100 );
+	}
+
+	/**
+	 * Flush all caches.
+	 *
+	 * @since 10.5.0
+	 */
+	public function flush_caches(): void {
+		global $wpdb, $wp_object_cache;
+
+		$wpdb->queries = array();
+
+		wp_cache_flush();
+
+		if ( ! is_object( $wp_object_cache ) ) {
+			return;
+		}
+
+		// These properties exist on various object cache implementations.
+		$wp_object_cache->group_ops      = array(); // @phpstan-ignore property.notFound
+		$wp_object_cache->stats          = array(); // @phpstan-ignore property.notFound
+		$wp_object_cache->memcache_debug = array(); // @phpstan-ignore property.notFound
+		$wp_object_cache->cache          = array(); // @phpstan-ignore property.notFound
+
+		// This method is specific to certain memcached implementations.
+		if ( method_exists( $wp_object_cache, '__remoteset' ) ) {
+			$wp_object_cache->__remoteset(); // important.
+		}
+
+		$this->collect_garbage();
+	}
+
+	/**
+	 * Collect garbage.
+	 */
+	private function collect_garbage(): void {
+		static $gc_threshold         = 5000;
+		static $gc_too_low_in_a_row  = 0;
+		static $gc_too_high_in_a_row = 0;
+
+		$gc_threshold_step = 2_500;
+		$gc_status         = gc_status();
+
+		if ( $gc_threshold > $gc_status['threshold'] ) {
+			// If PHP managed to collect memory in the meantime and established threshold lower than ours, just use theirs.
+			$gc_threshold = $gc_status['threshold'];
+		}
+
+		if ( $gc_status['roots'] > $gc_threshold ) {
+			$collected = gc_collect_cycles();
+			if ( $collected < 100 ) {
+				if ( $gc_too_low_in_a_row > 0 ) {
+					$gc_too_low_in_a_row = 0;
+					// Raise GC threshold if we collected too little twice in a row.
+					$gc_threshold += $gc_threshold_step;
+					$gc_threshold  = min( $gc_threshold, 1_000_000_000, $gc_status['threshold'] );
+				} else {
+					++$gc_too_low_in_a_row;
+				}
+				$gc_too_high_in_a_row = 0;
+			} else {
+				if ( $gc_too_high_in_a_row > 0 ) {
+					$gc_too_high_in_a_row = 0;
+					// Lower GC threshold if we collected more than enough twice in a row.
+					$gc_threshold -= $gc_threshold_step;
+					$gc_threshold  = max( $gc_threshold, 5_000 );
+				} else {
+					++$gc_too_high_in_a_row;
+				}
+				$gc_too_low_in_a_row = 0;
+			}
+		}
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Utils/StringHelper.php b/plugins/woocommerce/src/Internal/ProductFeed/Utils/StringHelper.php
new file mode 100644
index 0000000000..09859d007a
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Utils/StringHelper.php
@@ -0,0 +1,57 @@
+<?php
+/**
+ *  String Helper class.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Utils;
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+/**
+ * String utility helper functions
+ *
+ * @since 10.5.0
+ */
+class StringHelper {
+	/**
+	 * Convert value to boolean string ('true' or 'false')
+	 *
+	 * @since 10.5.0
+	 *
+	 * @param mixed $value Value to convert.
+	 * @return string 'true' or 'false'.
+	 */
+	public static function bool_string( $value ): string {
+		if ( is_bool( $value ) ) {
+			return $value ? 'true' : 'false';
+		}
+		if ( is_scalar( $value ) || null === $value ) {
+			$value = strtolower( (string) $value );
+		} else {
+			$value = '';
+		}
+		return ( 'true' === $value || '1' === $value || 'yes' === $value ) ? 'true' : 'false';
+	}
+
+	/**
+	 * Truncate text to specified length
+	 *
+	 * @since 10.5.0
+	 *
+	 * @param string $text Text to truncate.
+	 * @param int    $max_length Maximum length.
+	 * @return string Truncated text.
+	 */
+	public static function truncate( string $text, int $max_length ): string {
+		if ( mb_strlen( $text ) > $max_length ) {
+			return mb_substr( $text, 0, $max_length );
+		}
+		return $text;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Feed/ProductWalkerTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Feed/ProductWalkerTest.php
new file mode 100644
index 0000000000..e41a235daf
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Feed/ProductWalkerTest.php
@@ -0,0 +1,276 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductFeed\Feed;
+
+use WC_Helper_Product;
+use WC_Product;
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\IntegrationInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Utils\MemoryManager;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductLoader;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductMapperInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedValidatorInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\WalkerProgress;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductWalker;
+
+/**
+ * ProductWalkerTest class.
+ */
+class ProductWalkerTest extends \WC_Unit_Test_Case {
+	/**
+	 * Test container.
+	 *
+	 * @var TestContainer
+	 */
+	private $test_container;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->test_container = wc_get_container();
+	}
+
+	/**
+	 * Clean up test fixtures.
+	 */
+	public function tearDown(): void {
+		parent::tearDown();
+		remove_all_filters( 'woocommerce_product_feed_args' );
+		$this->test_container->reset_all_replacements();
+	}
+
+	/**
+	 * Data provider for walker tests.
+	 *
+	 * @return array Test scenarios.
+	 */
+	public function provider_walker(): array {
+		return array(
+			'No Results'                        => array(
+				'number_of_products' => 0,
+				'batch_size'         => 10,
+				'add_args_filter'    => true,
+			),
+			'Single batch'                      => array(
+				'number_of_products' => 10,
+				'batch_size'         => 100,
+				'add_args_filter'    => false,
+			),
+			'Multiple batches, half last batch' => array(
+				'number_of_products' => 5 * 12 - 6,
+				'batch_size'         => 12,
+				'add_args_filter'    => true,
+			),
+			'Multiple batches, full last batch' => array(
+				'number_of_products' => 5 * 13,
+				'batch_size'         => 13,
+				'add_args_filter'    => false,
+			),
+			'High number of batches, proper memory management' => array(
+				'number_of_products' => 15 * 2,
+				'batch_size'         => 2,
+				'add_args_filter'    => false,
+			),
+		);
+	}
+
+	/**
+	 * Test the product walker with varying input and results.
+	 *
+	 * @param int  $number_of_products The number of products to generate.
+	 * @param int  $batch_size         The batch size to use.
+	 * @param bool $add_args_filter    Whether the args filter is present.
+	 *
+	 * @dataProvider provider_walker
+	 */
+	public function test_walker( int $number_of_products, int $batch_size, bool $add_args_filter ) {
+		/**
+		 * Prepare all mocked data.
+		 */
+
+		// There should be at least one iteration, even with zero products.
+		$expected_iterations = max( 1, (int) ceil( $number_of_products / $batch_size ) );
+
+		// Generate products, group them into resulting batches.
+		$loader_results     = array();
+		$generated_products = 0;
+		for ( $i = 0; $i < $expected_iterations; $i++ ) {
+			$page = array();
+			for ( $j = 1; $j <= $batch_size && $generated_products++ < $number_of_products; $j++ ) {
+				$page[] = WC_Helper_Product::create_simple_product();
+			}
+
+			$loader_results[] = (object) array(
+				'products'      => $page,
+				'total'         => $number_of_products,
+				'max_num_pages' => $expected_iterations,
+			);
+		}
+
+		// Additional parameters for the query.
+		$parent_exclude        = -156;
+		$additional_query_args = array(
+			'parent_exclude' => $parent_exclude,
+			'category'       => array( 'shirts' ),
+		);
+
+		// The 11th product will always be rejected due to a validation error.
+		$validation_compensation = ( $number_of_products > 10 ? 1 : 0 );
+
+		/**
+		 * Set up all dependencies, including mocks.
+		 */
+		$mock_loader = $this->createMock( ProductLoader::class );
+		$this->test_container->replace( ProductLoader::class, $mock_loader );
+
+		$mock_memory_manager = $this->createMock( MemoryManager::class );
+		$this->test_container->replace( MemoryManager::class, $mock_memory_manager );
+
+		$mock_feed = $this->createMock( FeedInterface::class );
+
+		// Setup everything that comes from the integration, and the integration itself.
+		$mock_mapper      = $this->createMock( ProductMapperInterface::class );
+		$mock_validator   = $this->createMock( FeedValidatorInterface::class );
+		$mock_integration = $this->createMock( IntegrationInterface::class );
+		$mock_integration->expects( $this->once() )->method( 'get_product_mapper' )->willReturn( $mock_mapper );
+		$mock_integration->expects( $this->once() )->method( 'get_feed_validator' )->willReturn( $mock_validator );
+
+		/**
+		 * Set up data & expectations.
+		 */
+		$mock_integration->expects( $this->once() )
+			->method( 'get_product_feed_query_args' )
+			->willReturn( $additional_query_args );
+
+		// Set up the expectationf or each batch.
+		$loaded_page = 0;
+		$mock_loader->expects( $this->exactly( $expected_iterations ) )
+			->method( 'get_products' )
+			->with(
+				$this->callback(
+					function ( $args ) use ( &$loaded_page, $batch_size, $add_args_filter, $parent_exclude ) {
+						// Check pagination.
+						$this->assertEquals( ++$loaded_page, $args['page'] );
+						$this->assertEquals( $batch_size, $args['limit'] );
+
+						// The argument coming from the factory method should be here.
+						$this->assertEquals( $parent_exclude, $args['parent_exclude'] );
+
+						// There would be a category, unless the filter removed it..
+						if ( $add_args_filter ) {
+							$this->assertArrayNotHasKey( 'category', $args );
+						} else {
+							$this->assertArrayHasKey( 'category', $args );
+							$this->assertEquals( array( 'shirts' ), $args['category'] );
+						}
+						return true;
+					}
+				)
+			)
+			->willReturnCallback(
+				function () use ( &$loader_results ) {
+					return array_shift( $loader_results );
+				}
+			);
+
+		// Set up the mapper.
+		$mock_mapper->expects( $this->exactly( $number_of_products ) )
+			->method( 'map_product' )
+			->with( $this->isInstanceOf( WC_Product::class ) )
+			->willReturnCallback(
+				function ( WC_Product $product ) {
+					return array(
+						'id' => $product->get_id(),
+					);
+				}
+			);
+
+		// Set up the validator.
+		$validated_products = 0;
+		$mock_validator->expects( $this->exactly( $number_of_products ) )
+			->method( 'validate_entry' )
+			->with( $this->isType( 'array' ), $this->isInstanceOf( WC_Product::class ) )
+			->willReturnCallback(
+				function ( array $mapped_data, WC_Product $product ) use ( &$validated_products ) {
+					$this->assertEquals( $product->get_id(), $mapped_data['id'] );
+
+					// Pick a "random" product to invalidate.
+					$validated_products++;
+					if ( 11 === $validated_products ) {
+						return array( 'error' => 'Some validation error' );
+					}
+					return array();
+				}
+			);
+
+		// Make sure that the field is initiated, added to, and ended.
+		$mock_feed->expects( $this->once() )->method( 'start' );
+		$mock_feed->expects( $this->once() )->method( 'end' );
+		$mock_feed->expects( $this->exactly( $number_of_products - $validation_compensation ) )
+			->method( 'add_entry' )
+			->with( $this->isType( 'array' ) );
+
+		// Make sure that progress is indicated correctly.
+		$processed_iterations = 0;
+		$walker_callback      = function ( WalkerProgress $progress ) use (
+			$number_of_products,
+			$expected_iterations,
+			&$processed_iterations,
+			$batch_size
+		) {
+			$this->assertEquals( $number_of_products, $progress->total_count );
+			$this->assertEquals( $expected_iterations, $progress->total_batch_count );
+			$this->assertEquals( ++$processed_iterations, $progress->processed_batches );
+			$this->assertEquals( min( $processed_iterations * $batch_size, $number_of_products ), $progress->processed_items );
+		};
+
+		if ( $add_args_filter ) {
+			// Add a filter that unsets the category query arg.
+			add_filter(
+				'woocommerce_product_feed_args',
+				function ( $args, $integration ) use ( $mock_integration ) {
+					$this->assertSame( $mock_integration, $integration );
+					unset( $args['category'] );
+					return $args;
+				},
+				10,
+				2
+			);
+		}
+
+		// Memory management: Always start with 90%. Eatch batch takes up 20%.
+		$available_memory = 90;
+		$mock_memory_manager->expects( $this->exactly( $expected_iterations + 1 ) )
+			->method( 'get_available_memory' )
+			->willReturnCallback(
+				function () use ( &$available_memory ) {
+					$available_memory -= 20;
+					return $available_memory;
+				}
+			);
+		// Flushing cashes frees up memory up to 46% (just a bit over half).
+		// So once memory gets low, it remains just above the threshold (half of 90% or 45%).
+		$flushes = max( 0, $expected_iterations - 1 );
+		$mock_memory_manager->expects( $this->exactly( $flushes ) )
+			->method( 'flush_caches' )
+			->willReturnCallback(
+				function () use ( &$available_memory ) {
+					$available_memory = 46;
+				}
+			);
+
+		/**
+		 * Finally, get the walker and go!
+		 */
+		$walker = ProductWalker::from_integration(
+			$mock_integration,
+			$mock_feed
+		);
+
+		$walker->set_batch_size( $batch_size );
+		$walker->walk( $walker_callback );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/ApiControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/ApiControllerTest.php
new file mode 100644
index 0000000000..48e2ab1cc3
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/ApiControllerTest.php
@@ -0,0 +1,108 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductFeed\Integrations\POSCatalog;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog\ApiController;
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog\AsyncGenerator;
+use PHPUnit\Framework\MockObject\MockObject;
+use WP_REST_Request;
+
+/**
+ * API controller test class.
+ */
+class ApiControllerTest extends \WC_Unit_Test_Case {
+	/**
+	 * System under test.
+	 *
+	 * @var ApiController
+	 */
+	private ApiController $sut;
+
+	/**
+	 * Mock async generator.
+	 *
+	 * @var MockObject|AsyncGenerator
+	 */
+	private $mock_async_generator;
+
+	/**
+	 * Test container.
+	 *
+	 * @var TestContainer
+	 */
+	private $test_container;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		// Reset first to ensure fresh instances.
+		$this->test_container       = wc_get_container();
+		$this->mock_async_generator = $this->createMock( AsyncGenerator::class );
+		$this->test_container->replace( AsyncGenerator::class, $this->mock_async_generator );
+
+		$this->sut = $this->test_container->get( ApiController::class );
+	}
+
+	/**
+	 * Clean up test fixtures.
+	 */
+	public function tearDown(): void {
+		parent::tearDown();
+		$this->test_container->reset_all_replacements();
+	}
+
+	/**
+	 * Data provider for generate_feed tests.
+	 *
+	 * @return array Test scenarios.
+	 */
+	public function provider_generate_feed(): array {
+		return array(
+			'No force-generation check, no fields'   => array( false, null ),
+			'No force-generation check, with fields' => array( false, 'id,name' ),
+			'Force generation, with fields'          => array( true, 'id,name' ),
+		);
+	}
+
+	/**
+	 * Test the generate_feed endpoint method.
+	 *
+	 * @dataProvider provider_generate_feed
+	 * @param bool        $force_regeneration Whether to force regeneration of the feed.
+	 * @param string|null $fields The fields to include in the feed.
+	 */
+	public function test_generate_feed( bool $force_regeneration, ?string $fields = null ) {
+		$request = new WP_REST_Request( 'POST', '/wc/pos/v1/catalog/create' );
+
+		if ( $force_regeneration ) {
+			$request->set_param( 'force', true );
+		}
+		if ( $fields ) {
+			$request->set_param( '_product_fields', $fields );
+		}
+
+		$this->mock_async_generator->expects( $this->once() )
+			->method( $force_regeneration ? 'force_regeneration' : 'get_status' )
+			->with( $fields ? array( '_product_fields' => $fields ) : array() )
+			->willReturn(
+				array(
+					'action_id' => 6789,
+					'path'      => '/tmp/random_path.json',
+					'url'       => 'https://example.com/feed.json',
+				)
+			);
+
+		$response      = $this->sut->generate_feed( $request );
+		$response_data = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertArrayNotHasKey( 'action_id', $response_data );
+		$this->assertArrayNotHasKey( 'path', $response_data );
+		$this->assertArrayHasKey( 'url', $response_data );
+		$this->assertEquals( 'https://example.com/feed.json', $response_data['url'] );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGeneratorTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGeneratorTest.php
new file mode 100644
index 0000000000..cd7fd0de06
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/AsyncGeneratorTest.php
@@ -0,0 +1,106 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductFeed\Integrations\POSCatalog;
+
+use PHPUnit\Framework\MockObject\MockObject;
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog\AsyncGenerator;
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog\POSIntegration;
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog\ProductMapper;
+use WC_Helper_Product;
+
+/**
+ * Async generator test class.
+ */
+class AsyncGeneratorTest extends \WC_Unit_Test_Case {
+	/**
+	 * System under test.
+	 *
+	 * @var AsyncGenerator
+	 */
+	private AsyncGenerator $sut;
+
+	/**
+	 * Mock integration.
+	 *
+	 * @var MockObject|POSIntegration
+	 */
+	private $mock_integration;
+
+	/**
+	 * Test container.
+	 *
+	 * @var TestContainer
+	 */
+	private $test_container;
+
+	// Option key for tests.
+	private const OPTION_KEY = 'product_feed_async_test';
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		// Reset first to ensure AsyncGenerator gets the mock, not a cached real instance.
+		$this->test_container = wc_get_container();
+
+		$this->mock_integration = $this->createMock( POSIntegration::class );
+		$this->test_container->replace( POSIntegration::class, $this->mock_integration );
+
+		$this->sut = $this->test_container->get( AsyncGenerator::class );
+	}
+
+	/**
+	 * Clean up test fixtures.
+	 */
+	public function tearDown(): void {
+		parent::tearDown();
+
+		delete_option( self::OPTION_KEY );
+		$this->test_container->reset_all_replacements();
+	}
+
+	/**
+	 * Test that feed generation action forwards arguments to mapper.
+	 */
+	public function test_feed_generation_action_forwards_args() {
+		// Make sure at least one product is present. We will not check it here.
+		WC_Helper_Product::create_simple_product();
+
+		// Set the initial option to indicate scheduled state.
+		$status = array(
+			'state' => AsyncGenerator::STATE_SCHEDULED,
+			'args'  => array(
+				'_product_fields'   => 'id,name',
+				'_variation_fields' => 'id,name,url',
+			),
+		);
+		update_option( self::OPTION_KEY, $status );
+
+		// Expect the mapper to be called with the fields.
+		$mock_mapper = $this->createMock( ProductMapper::class );
+		$mock_mapper->expects( $this->once() )
+			->method( 'set_fields' )
+			->with( 'id,name' );
+		$mock_mapper->expects( $this->once() )
+			->method( 'set_variation_fields' )
+			->with( 'id,name,url' );
+		$mock_mapper->expects( $this->atLeast( 1 ) )
+			->method( 'map_product' )
+			->willReturn( array() );
+
+		// Replace the mapper with the integration.
+		$this->mock_integration->expects( $this->atLeast( 1 ) )
+			->method( 'get_product_mapper' )
+			->willReturn( $mock_mapper );
+
+		// Trigger the action.
+		$this->sut->feed_generation_action( self::OPTION_KEY );
+
+		// Check the final status.
+		$updated_status = get_option( self::OPTION_KEY );
+		$this->assertEquals( AsyncGenerator::STATE_COMPLETED, $updated_status['state'] );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/ProductMapperTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/ProductMapperTest.php
new file mode 100644
index 0000000000..eef0855104
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/ProductMapperTest.php
@@ -0,0 +1,84 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductFeed\Integrations\POSCatalog;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog\ProductMapper;
+use WC_Helper_Product;
+
+/**
+ * Product mapper test class.
+ */
+class ProductMapperTest extends \WC_Unit_Test_Case {
+	/**
+	 * System under test.
+	 *
+	 * @var ProductMapper
+	 */
+	private ProductMapper $sut;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->sut = new ProductMapper();
+		$this->sut->init(); // Could be done through DI, but there are no parameter dependencies.
+	}
+
+	/**
+	 * Clean up test fixtures.
+	 */
+	public function tearDown(): void {
+		parent::tearDown();
+
+		$this->sut->set_fields( null );
+	}
+
+	/**
+	 * Test mapping a simple product.
+	 */
+	public function test_map_product_simple_product(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_name( 'Test Product' );
+		$product->set_description( 'Test Description' );
+		$product->set_regular_price( '99.99' );
+
+		$result = $this->sut->map_product( $product );
+
+		$this->assertIsArray( $result );
+		$this->assertArrayHasKey( 'type', $result );
+		$this->assertArrayHasKey( 'data', $result );
+		$this->assertArrayHasKey( 'id', $result['data'] );
+		$this->assertArrayHasKey( 'name', $result['data'] );
+		$this->assertArrayHasKey( 'description', $result['data'] );
+		$this->assertArrayHasKey( 'price', $result['data'] );
+		$this->assertArrayHasKey( 'downloadable', $result['data'] );
+		$this->assertArrayHasKey( 'parent_id', $result['data'] );
+		$this->assertArrayHasKey( 'images', $result['data'] );
+	}
+
+	/**
+	 * Test mapping a product with specific fields.
+	 */
+	public function test_map_product_with_fields(): void {
+		$this->sut->set_fields( 'id,name,description' );
+
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_name( 'Test Product' );
+		$product->set_description( 'Test Description' );
+		$product->set_regular_price( '99.99' );
+
+		$result = $this->sut->map_product( $product );
+
+		$this->assertIsArray( $result );
+		$this->assertArrayHasKey( 'type', $result );
+		$this->assertArrayHasKey( 'data', $result );
+		$this->assertArrayHasKey( 'name', $result['data'] );
+		$this->assertArrayHasKey( 'description', $result['data'] );
+		$this->assertArrayNotHasKey( 'price', $result['data'] );
+		$this->assertArrayNotHasKey( 'downloadable', $result['data'] );
+		$this->assertArrayNotHasKey( 'parent_id', $result['data'] );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Storage/JsonFileFeedTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Storage/JsonFileFeedTest.php
new file mode 100644
index 0000000000..21947be02c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Storage/JsonFileFeedTest.php
@@ -0,0 +1,157 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductFeed\Storage;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Storage\JsonFileFeed;
+
+// This file works directly with local files. That's fine.
+// phpcs:disable WordPress.WP.AlternativeFunctions
+
+if ( ! function_exists( 'WP_Filesystem' ) ) {
+	require_once ABSPATH . 'wp-admin/includes/file.php';
+}
+
+
+/**
+ * JsonFileFeedTest class.
+ */
+class JsonFileFeedTest extends \WC_Unit_Test_Case {
+	/**
+	 * Clean up test fixtures.
+	 */
+	public function tearDown(): void {
+		parent::tearDown();
+		$this->get_and_delete_dir();
+		remove_all_filters( 'woocommerce_product_feed_time' );
+	}
+
+	/**
+	 * Test that feed file is created correctly.
+	 */
+	public function test_feed_file_is_created() {
+		// Use the current time for the test as the time in the SUT to avoid flakiness.
+		$current_time = time();
+		add_filter( 'woocommerce_product_feed_time', fn() => $current_time );
+
+		$feed = new JsonFileFeed( 'test-feed' );
+		$feed->start();
+		$feed->end();
+
+		// The file should be in `/tmp` at first.
+		$path = $feed->get_file_path();
+		$this->assertStringStartsWith( get_temp_dir(), $path );
+		$this->assertStringContainsString( gmdate( 'Y-m-d', $current_time ), $path );
+		$this->assertStringContainsString( wp_hash( 'test-feed' . gmdate( 'r', $current_time ) ), $path );
+		$this->assertTrue( file_exists( $path ) );
+		$this->assertEquals( '[]', file_get_contents( $path ) );
+
+		// Once a URL is retrieved, the file will be moved to the uploads dir.
+		$url   = $feed->get_file_url();
+		$path2 = $feed->get_file_path();
+		$this->assertNotNull( $url );
+		$this->assertStringContainsString( 'uploads/product-feeds', $path2 );
+		$this->assertStringEndsWith( '.json', (string) $url );
+		$this->assertStringContainsString( '/product-feeds/', (string) $url );
+	}
+
+	/**
+	 * Test that feed file is created with entries.
+	 */
+	public function test_feed_file_is_created_with_entries() {
+		$data = array(
+			array(
+				'name'  => 'First Entry',
+				'price' => 100,
+			),
+			array(
+				'name'  => 'Second Entry',
+				'price' => 333,
+			),
+		);
+
+		$feed = new JsonFileFeed( 'test-feed' );
+		$feed->start();
+		foreach ( $data as $entry ) {
+			$feed->add_entry( $entry );
+		}
+		$feed->end();
+
+		$this->assertEquals(
+			wp_json_encode( $data ),
+			file_get_contents( $feed->get_file_path() )
+		);
+	}
+
+	/**
+	 * Test that get_file_url returns null if feed is not completed.
+	 */
+	public function test_get_file_url_returns_null_if_not_completed() {
+		$feed = new JsonFileFeed( 'test-feed' );
+		$feed->start();
+		$this->assertNull( $feed->get_file_url() );
+		$feed->end();
+	}
+
+	/**
+	 * Test that add_entry before start is a no-op (does not throw).
+	 */
+	public function test_add_entry_before_start_is_noop() {
+		$feed = new JsonFileFeed( 'test-feed' );
+		// Should not throw - silently returns when file handle is not ready.
+		$feed->add_entry( array( 'name' => 'oops' ) );
+		$this->assertNull( $feed->get_file_path() );
+	}
+
+	/**
+	 * Test that end before start is a no-op (does not throw).
+	 */
+	public function test_end_before_start_is_noop() {
+		$feed = new JsonFileFeed( 'test-feed' );
+		// Should not throw - silently returns when file handle is not ready.
+		$feed->end();
+		$this->assertNull( $feed->get_file_path() );
+	}
+
+	/**
+	 * Test that get_file_url throws when directory cannot be created.
+	 */
+	public function test_get_file_url_throws_when_directory_cannot_be_created() {
+		// Ensure clean state then create a FILE where the directory should be.
+		$this->get_and_delete_dir();
+		$uploads_dir = wp_upload_dir()['basedir'];
+		$block_path  = $uploads_dir . '/product-feeds';
+
+		// Create a file to block directory creation.
+		file_put_contents( $block_path, 'blocking file' );
+
+		$this->expectException( \Exception::class );
+
+		try {
+			$feed = new JsonFileFeed( 'test-feed' );
+			$feed->start();
+			$feed->end();
+			$feed->get_file_url();
+		} finally {
+			// Cleanup: remove blocking file.
+			if ( file_exists( $block_path ) && is_file( $block_path ) ) {
+				unlink( $block_path );
+			}
+		}
+	}
+
+	/**
+	 * Gets the directory for feed files, but also deletes it.
+	 *
+	 * @return string The directory path.
+	 */
+	private function get_and_delete_dir(): string {
+		$directory = wp_upload_dir()['basedir'] . '/product-feeds';
+		if ( is_dir( $directory ) ) {
+			global $wp_filesystem;
+			WP_Filesystem();
+			$wp_filesystem->rmdir( $directory, true );
+		}
+		return $directory;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Utils/StringHelperTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Utils/StringHelperTest.php
new file mode 100644
index 0000000000..d7d48e16bf
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Utils/StringHelperTest.php
@@ -0,0 +1,25 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductFeed\Utils;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Utils\StringHelper;
+
+/**
+ * StringHelper test class.
+ */
+class StringHelperTest extends \WC_Unit_Test_Case {
+	/**
+	 * Simple assertion.
+	 */
+	public function test_demo() {
+		$this->assertTrue( true );
+	}
+
+	/**
+	 * Test a static method to make sure the autoloader works.
+	 */
+	public function test_plugin_class() {
+		$this->assertEquals( 'true', StringHelper::bool_string( 'yes' ) );
+	}
+}