Commit 3724cacfaa6 for woocommerce

commit 3724cacfaa6c2ae3700c79b046cdc4bdc837b6ca
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date:   Tue May 12 14:44:00 2026 +0300

    Add AJAX submission handler for the Review Order form (#64527)

    * Add accessible star-rating control for Review Order page

    Native `<input type="radio">` ✕ 5 wrapped in a `role="radiogroup"` container,
    visually replaced by SVG stars. Progressive enhancement adds:

    - Keyboard nav: ArrowLeft/Up, ArrowRight/Down, Home, End. Selecting also
      moves focus and dispatches `change`, so any future submission listener
      reacts naturally.
    - Dynamic caption underneath the stars, populated from the focused/checked
      radio's `data-label`. Defaults match the existing WC product-review
      labels (Very poor / Not that bad / Average / Good / Perfect) and are
      filterable via `woocommerce_review_order_rating_labels`.
    - High-contrast focus ring on the wrapping label whenever its input is
      `:focus-visible`.
    - Hover preview lights stars left of the cursor; selection persists when
      the cursor leaves.

    Without JavaScript the underlying radio group still works as a plain
    form field, so the page stays functional.

    Files:

    - `src/Internal/OrderReviews/StarRating.php` — server-side renderer.
      Builds the markup and exposes `get_labels()` (filter-aware, with safe
      fallbacks for buggy filters that drop slot keys).
    - `templates/order/star-rating.php` — theme-overridable partial.
    - `client/legacy/js/frontend/order-review.js` — vanilla JS, no jQuery.
    - `client/legacy/css/order-review.scss` — visual states.
    - `Internal/OrderReviews/Endpoint::enqueue_assets()` — script + style
      enqueued only when the review-order endpoint is actually rendering.
    - `templates/order/customer-review-order.php` — temporary preview render
      per item so reviewers can interact with the control. M4 (WOOPLUG-6595)
      swaps this preview for the real per-item form row.

    Tests:

    - `Internal\OrderReviews\StarRatingTest` — markup, defaults, filter
      override, filter fallback for missing keys.

    Closes #64308 (WOOPLUG-6594).

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

    * Remove duplicate changelog file (workflow generates the canonical one)

    * Address Copilot feedback on star-rating control PR

    - Route enqueue URLs through woocommerce_get_asset_url filter so
      CDN/asset-rewrite setups continue to work.
    - Add screen-reader-only 'Required' text alongside the visual asterisk
      on the rating label.
    - Correct the JS file header comment: only Arrow keys + Home/End are
      handled, not Space/Enter.

    * Fix StarRating docblock to use flat @param syntax

    The PHPDoc shape syntax (array{...}) trips the WC PHPCS sniff, which
    expects a single @param line per parameter. Reverted to plain
    @param array with the key descriptions in the body.

    * Drop the planning comment + minor tidy on star-rating styles

    * Load the RTL stylesheet variant on RTL sites

    wp_style_add_data( 'wc-order-review', 'rtl', 'replace' ) tells the
    classic-assets pipeline to swap order-review.css for order-review-rtl.css
    on right-to-left locales.

    * Trim defensive fallbacks and dedupe asset enqueue helpers

    - StarRating::get_labels(): replace the manual 1-5 fallback loop and
      reorder block with array_replace + array_intersect_key.
    - StarRating::render(): drop the unused $selected pre-cast; read it
      inline only when it's used.
    - Endpoint::enqueue_assets(): factor a local closure for the
      woocommerce_get_asset_url filter so we don't repeat the same call
      for the style and the script.
    - order/star-rating.php template: drop the data-caption-id attribute
      that duplicated aria-describedby.
    - order-review.js: read the caption id from aria-describedby and drop
      the empty-input early-out (init() already filters by class).

    No behaviour change. All six StarRating tests still pass.

    * Address review feedback on the star-rating control

    - Add @since 10.8.0 annotations on StarRating::render() and
      StarRating::get_labels() docblocks (per ayushpahwa).
    - Bound the `selected` argument: values outside 0-5 fall back to
      no-selection rather than passing an unrenderable value to the
      template (per ayushpahwa).
    - Cover the bound with a regression test.

    * Address remaining review feedback on the star-rating control

    - CSS: drop every `:has()` selector. The template now renders the
      inputs in reverse DOM order (5..1) and uses `flex-direction:
      row-reverse`; selected/hover/focus states are driven by the `~`
      sibling combinator and `:focus-visible + label`. Caption gets
      `order: -1` so it stays on the visual right.
    - JS: keyboard navigation flips so ArrowRight/Down still moves
      visually right (DOM-previous) and Home/End map to the visual
      leftmost/rightmost stars.
    - Template: pre-compute `$initial_caption` so the caption element no
      longer mixes selection logic, escaping, and output in a single
      expression.
    - Endpoint: enqueue the JS with `strategy => defer` (in addition to
      `in_footer => true`) since it only attaches DOM-ready listeners.

    * Add accessible star-rating control for Review Order page

    Native `<input type="radio">` ✕ 5 wrapped in a `role="radiogroup"` container,
    visually replaced by SVG stars. Progressive enhancement adds:

    - Keyboard nav: ArrowLeft/Up, ArrowRight/Down, Home, End. Selecting also
      moves focus and dispatches `change`, so any future submission listener
      reacts naturally.
    - Dynamic caption underneath the stars, populated from the focused/checked
      radio's `data-label`. Defaults match the existing WC product-review
      labels (Very poor / Not that bad / Average / Good / Perfect) and are
      filterable via `woocommerce_review_order_rating_labels`.
    - High-contrast focus ring on the wrapping label whenever its input is
      `:focus-visible`.
    - Hover preview lights stars left of the cursor; selection persists when
      the cursor leaves.

    Without JavaScript the underlying radio group still works as a plain
    form field, so the page stays functional.

    Files:

    - `src/Internal/OrderReviews/StarRating.php` — server-side renderer.
      Builds the markup and exposes `get_labels()` (filter-aware, with safe
      fallbacks for buggy filters that drop slot keys).
    - `templates/order/star-rating.php` — theme-overridable partial.
    - `client/legacy/js/frontend/order-review.js` — vanilla JS, no jQuery.
    - `client/legacy/css/order-review.scss` — visual states.
    - `Internal/OrderReviews/Endpoint::enqueue_assets()` — script + style
      enqueued only when the review-order endpoint is actually rendering.
    - `templates/order/customer-review-order.php` — temporary preview render
      per item so reviewers can interact with the control. M4 (WOOPLUG-6595)
      swaps this preview for the real per-item form row.

    Tests:

    - `Internal\OrderReviews\StarRatingTest` — markup, defaults, filter
      override, filter fallback for missing keys.

    Closes #64308 (WOOPLUG-6594).

    * Add per-item review form row and submit gate

    Replaces the temporary preview render from #64525 with the real per-item
    form that the Customer Review Request page submits to. Submission handler
    itself lands in M4 (WOOPLUG-6596).

    `templates/order/customer-review-order-row.php` (new, theme-overridable):

    - Linked product title that opens the product page in a new tab.
    - 120×120 thumbnail.
    - Hidden `product_id` and `order_item_id` so the row is identifiable
      on submit.
    - The accessible star-rating control from #64525, indexed by `row_index`
      for stable POST data.
    - Textarea labelled "Your review" with the i18n placeholder
      "Share your experience with this product...".
    - `do_action( 'woocommerce_review_order_form_fields', $item, $product,
      $order, $row_index )` after the textarea so extensions can inject
      fields without overriding the whole template.

    `templates/order/customer-review-order.php`:

    - Wraps the item list in `<form class="woocommerce-review-order__form"
      method="post" action="">` with a hidden `order_id`, hidden `key`
      (echoed back from the URL the Endpoint already validated), and the
      `woocommerce_submit_order_reviews` nonce.
    - "Submit reviews" button rendered disabled by default. The JS module
      enables it as soon as any row has a rating selected.

    `client/legacy/js/frontend/order-review.js` gains `initSubmitGate(form)`,
    which listens for `change` events on `.woocommerce-star-rating__input`
    inside the form and toggles the button's `disabled` state.

    - StarRating's docblock simplified to plain `@param array` so the WC
      PHPCS sniffs don't misparse the structured array shorthand.

    Closes #64309 (WOOPLUG-6595).

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

    * Drop M-numbered milestone references from per-item row templates

    * Strip non-layout styling from per-item row; let theme drive typography

    * Stack the per-item row vertically on viewports under 600px

    Folded in from the (now-closed) design-tokens-responsive PR; the only
    contribution that survived the simplification was the 12-line media
    query, which fits naturally with the per-item layout introduced here.

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

    * Wire customer-review-order.php to the per-item row partial

    The 6595 changes had been dropped during cascading rebases: the form
    wrapper, hidden inputs, foreach loop calling
    wc_get_template('order/customer-review-order-row.php', ...), and the
    submit button were all missing. Restored so each item actually renders
    the row partial and the submit gate's selectors exist in the DOM.

    * Strengthen the new-tab product link

    - rel='noopener noreferrer' (was just 'noopener') to also prevent
      referrer leakage in some browsers/configurations.
    - Add a .screen-reader-text 'opens in a new tab' suffix so assistive
      tech users are warned the link spawns a new window.

    * Address per-item-row review feedback

    - Collapse the .woocommerce-review-order__item top border to a single
      declaration; the previous form layered border-top-color over a
      currentColor border, which was redundant.
    - Log a console.warn from initSubmitGate when the submit button is
      missing so theme overrides that drop it surface the cause.

    * Wrap the long console.warn message to satisfy eslint max-len

    * Add AJAX submission handler for the Review Order form

    `Internal\OrderReviews\SubmissionHandler` registers
    `wp_ajax{,_nopriv}_woocommerce_submit_order_reviews`. For each rated row
    it inserts a `comment_type='review'` comment against the product, with
    `rating` (1-5) and `verified=1` commentmeta. Author fields are pulled
    from the order; logged-in customers must own the order, while guests
    authenticate via the order key.

    Per-row outcome is `ok`, `pending_moderation`, or `error`. One row's
    failure does not block the rest. The response shape:

    ```json
    {
      "success": true,
      "data": {
        "results": {
          "<row_index>": {
            "product_id": 123,
            "status":     "ok",
            "comment_id": 456
          }
        }
      }
    }
    ```

    `get_option('comment_moderation')` is honoured: when on, comments are
    inserted with `comment_approved=0` and the row reports
    `pending_moderation`.

    `woocommerce_review_order_submitted` action fires once per submission
    with the order and the per-row results array.

    `_wc_review_request_completed_at` order meta is set when every line
    item on the order has at least one comment by the customer's billing
    email — no matter whether those reviews were posted in this submission
    or earlier.

    JS:

    - `client/legacy/js/frontend/order-review.js` intercepts the form's
      `submit`, posts via `fetch` to admin-ajax, renders per-row status
      messages from the JSON response.
    - The form's `action` falls back to admin-ajax.php for the no-JS case
      (handler still works server-side).

    Wrapper:

    - `OrderReviews::init()` now lists `SubmissionHandler` as a third arg,
      so the container resolves and auto-init's it. No `class-woocommerce.php`
      changes needed.

    Tests:

    - `Internal\OrderReviews\SubmissionHandlerTest` covers bad nonce, bad
      key, valid insert + meta, empty-rating skip, `comment_moderation`,
      per-row isolation when one row references a product not on the
      order, and the completed-at meta on full vs partial submission.

    Closes #64310 (WOOPLUG-6596).

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

    * Address Copilot feedback on AJAX submission handler PR

    - Gate submissions on the same woocommerce_review_order_eligible_statuses
      filter the page-load endpoint uses, so a tampered POST with a valid key
      cannot bypass status restrictions.
    - Stop double-unslashing the row text. handle() already unslashes the
      whole reviews array; process_rows() trusted that.
    - Make maybe_mark_order_complete() idempotent: skip when the meta is
      already set, and replace the per-product N+1 lookup with a single
      grouped get_comments() call.
    - Localize the JS status messages via wp_localize_script() instead of
      hardcoding English strings client-side.
    - Test housekeeping: remove the AJAX/JSON filters in tearDown(), and add
      a test for the woocommerce_review_order_submitted action plus an
      ineligible-status rejection test.
    - Sanitize key and nonce inputs explicitly so the WordPress sniffs pass
      on the changed lines.

    * Drop empty meta_query arg that triggers WC slow-query lint

    The empty meta_query => array() option was redundant and the WC sniff
    flags the key's presence as a possible slow query.

    * Address Copilot feedback (round 2) on AJAX submission handler PR

    - Out-of-range ratings (e.g. 6) no longer disappear: only rating==0 is
      treated as a deliberate skip; values <1 or >5 now report an
      invalid_rating error per row.
    - maybe_mark_order_complete() fetches comment objects directly so the
      comment_post_ID lookup doesn't need a follow-up get_comment() per
      row (was a regression of the original N+1).
    - Submit gate is now reused after AJAX completion: expose syncSubmit
      on the form, and initAjaxSubmit calls it instead of unconditionally
      enabling the button. The button stays disabled when no rows have a
      rating selected (e.g. after a failed submit + cleared rating).

    * Add per-row status note style; source colors from WC --wc-green / --wc-red

    Avoids introducing new --wc-review-order-* tokens. The active theme
    controls everything else; we only need ok/error tinting on the inline
    status message and we use the WC variables that already exist at :root.

    * Point the Review Order form at admin-ajax for the new submission handler

    Adds action + data-ajax-url + the action hidden input so the JS submit
    gate's fetch() target and the no-JS POST both reach the handler.

    * Accept variation_id; exclude spam/trash from completion check

    - The Review Order row template posts the variation id for variable
      products, but $item->get_product_id() returns the parent. Accept
      either match and store the review against the parent so it shows on
      the product page regardless of which variation was bought.
    - Switch the completion lookup from status=>all to status=>[approve,hold]
      so spam/trash reviews can't trip _wc_review_request_completed_at.
    - Fix the maybe_mark_order_complete() docblock so its 'verified review'
      wording matches the actual behaviour (any non-spam/non-trash review
      by the customer counts).

    * Filter the network-failure error path to rows with a rating

    The catch handler renders an error on every item row, but the earlier
    'success: false' branch only flags rows that were actually rated. A
    network failure mid-submit now incorrectly tells the customer the rows
    they intentionally skipped also failed; mirror the existing 'has a
    checked rating' guard so only rated rows surface the error.

    * Address review feedback on the AJAX submission handler

    - maybe_mark_order_complete(): build the eligible-row set through the
      same `woocommerce_review_order_eligible_items` filter the page uses,
      count required reviews per parent product (so duplicate or
      multi-variation rows need their full count of reviews), and pass
      number=>0 to get_comments() so the default 20-row cap doesn't
      produce false negatives on large orders.
    - process_rows(): only attribute the inserted comment to a WP user
      when the current request is authenticated as that user. Guests
      reaching the page via the order key now get user_id=0 instead of
      the order's customer id.
    - customer-review-order.php: wrap the hidden `action` value in
      esc_attr() for consistency with adjacent hidden inputs.
    - SubmissionHandlerTest: add coverage for the `invalid_rating` and
      `product_mismatch` error codes (40 tests, was 38).

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/64527-wooplug-6596-ajax-submission-handler b/plugins/woocommerce/changelog/64527-wooplug-6596-ajax-submission-handler
new file mode 100644
index 00000000000..7f08b2c3623
--- /dev/null
+++ b/plugins/woocommerce/changelog/64527-wooplug-6596-ajax-submission-handler
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the AJAX submission handler that turns the Review Order form into product reviews — one verified-buyer comment per rated row, per-row outcome, `comment_moderation` honoured.
\ No newline at end of file
diff --git a/plugins/woocommerce/client/legacy/css/order-review.scss b/plugins/woocommerce/client/legacy/css/order-review.scss
index da3954e638d..42e7051ed52 100644
--- a/plugins/woocommerce/client/legacy/css/order-review.scss
+++ b/plugins/woocommerce/client/legacy/css/order-review.scss
@@ -52,6 +52,20 @@
 		resize: vertical;
 	}

+	// Submission status note. Falls back to literal hex if a stylesheet
+	// loads before _variables.scss exposes the WC color tokens.
+	&__item-status {
+		margin: 0.5em 0 0;
+
+		&--ok {
+			color: var(--wc-green, #008a20);
+		}
+
+		&--error {
+			color: var(--wc-red, #a00);
+		}
+	}
+
 	@media (max-width: 600px) {
 		&__item-row {
 			flex-direction: column;
diff --git a/plugins/woocommerce/client/legacy/js/frontend/order-review.js b/plugins/woocommerce/client/legacy/js/frontend/order-review.js
index 9a6810368d0..629e34d1b26 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/order-review.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/order-review.js
@@ -99,8 +99,8 @@
 			submit.disabled = ! anyChecked;
 		}

-		// Expose so external code (e.g. the AJAX submission handler in #64527)
-		// can re-evaluate the gate after async state changes.
+		// Expose so initAjaxSubmit can re-run the gate after the request
+		// completes (instead of unconditionally enabling the button).
 		form.syncReviewOrderSubmitGate = syncSubmit;

 		form.addEventListener( 'change', function ( event ) {
@@ -118,6 +118,135 @@
 		syncSubmit();
 	}

+	/**
+	 * Render per-row outcome inside a row's fields container.
+	 *
+	 * @param {HTMLElement} row    `.woocommerce-review-order__item`
+	 * @param {string}      status `ok | pending_moderation | error`
+	 * @param {string}      [text] Optional message override.
+	 */
+	function renderRowStatus( row, status, text ) {
+		var fields = row.querySelector(
+			'.woocommerce-review-order__item-fields'
+		);
+		if ( ! fields ) {
+			return;
+		}
+		var existing = fields.querySelector(
+			'.woocommerce-review-order__item-status'
+		);
+		if ( existing ) {
+			existing.parentNode.removeChild( existing );
+		}
+		var i18n =
+			( window.wcOrderReview && window.wcOrderReview.i18n ) || {};
+		var defaults = {
+			ok: i18n.ok || 'Thanks, your review is live.',
+			pending_moderation:
+				i18n.pending_moderation ||
+				'Thanks, your review is pending approval.',
+			error:
+				i18n.error || 'Something went wrong, please try again.',
+		};
+		var note = document.createElement( 'p' );
+		note.className =
+			'woocommerce-review-order__item-status woocommerce-review-order__item-status--' +
+			status;
+		note.setAttribute( 'role', 'status' );
+		note.textContent = text || defaults[ status ] || defaults.error;
+		fields.appendChild( note );
+	}
+
+	/**
+	 * Intercept form submit and POST it to admin-ajax.
+	 *
+	 * @param {HTMLFormElement} form
+	 */
+	function initAjaxSubmit( form ) {
+		var ajaxUrl = form.getAttribute( 'data-ajax-url' );
+		if ( ! ajaxUrl ) {
+			return;
+		}
+
+		form.addEventListener( 'submit', function ( event ) {
+			event.preventDefault();
+
+			var submit = form.querySelector(
+				'.woocommerce-review-order__submit'
+			);
+			if ( submit ) {
+				submit.disabled = true;
+			}
+
+			window
+				.fetch( ajaxUrl, {
+					method: 'POST',
+					credentials: 'same-origin',
+					body: new window.FormData( form ),
+				} )
+				.then( function ( response ) {
+					return response.json().catch( function () {
+						return { success: false };
+					} );
+				} )
+				.then( function ( payload ) {
+					if ( ! payload || ! payload.success || ! payload.data ) {
+						Array.prototype.forEach.call(
+							form.querySelectorAll(
+								'.woocommerce-review-order__item'
+							),
+							function ( row ) {
+								if (
+									row.querySelector(
+										'.woocommerce-star-rating__input:checked'
+									)
+								) {
+									renderRowStatus( row, 'error' );
+								}
+							}
+						);
+						return;
+					}
+
+					var results = payload.data.results || {};
+					Object.keys( results ).forEach( function ( key ) {
+						var entry = results[ key ];
+						var row = form.querySelector(
+							'.woocommerce-review-order__item[data-row-index="' +
+								key +
+								'"]'
+						);
+						if ( row && entry && entry.status ) {
+							renderRowStatus( row, entry.status );
+						}
+					} );
+				} )
+				.catch( function () {
+					Array.prototype.forEach.call(
+						form.querySelectorAll(
+							'.woocommerce-review-order__item'
+						),
+						function ( row ) {
+							if (
+								row.querySelector(
+									'.woocommerce-star-rating__input:checked'
+								)
+							) {
+								renderRowStatus( row, 'error' );
+							}
+						}
+					);
+				} )
+				.then( function () {
+					if ( typeof form.syncReviewOrderSubmitGate === 'function' ) {
+						form.syncReviewOrderSubmitGate();
+					} else if ( submit ) {
+						submit.disabled = false;
+					}
+				} );
+		} );
+	}
+
 	function init() {
 		var groups = document.querySelectorAll( '.woocommerce-star-rating' );
 		Array.prototype.forEach.call( groups, initGroup );
@@ -126,6 +255,7 @@
 			'.woocommerce-review-order__form'
 		);
 		Array.prototype.forEach.call( forms, initSubmitGate );
+		Array.prototype.forEach.call( forms, initAjaxSubmit );
 	}

 	if ( document.readyState === 'loading' ) {
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 36539c18016..f2aed327111 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -378,6 +378,7 @@ final class WooCommerce {
 		$container->get( TaxRateVersionStringInvalidator::class );
 		$container->get( Automattic\WooCommerce\Internal\OrderReviews\Scheduler::class );
 		$container->get( Automattic\WooCommerce\Internal\OrderReviews\Endpoint::class );
+		$container->get( Automattic\WooCommerce\Internal\OrderReviews\SubmissionHandler::class );

 		// Feature flags.
 		if ( Constants::is_true( 'WOOCOMMERCE_BIS_ALPHA_ENABLED' ) ) {
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php b/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php
index 4e336a01871..3d3cadcd73b 100644
--- a/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php
+++ b/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php
@@ -433,6 +433,18 @@ class Endpoint {
 				'in_footer' => true,
 			)
 		);
+
+		wp_localize_script(
+			'wc-order-review',
+			'wcOrderReview',
+			array(
+				'i18n' => array(
+					'ok'                 => __( 'Thanks, your review is live.', 'woocommerce' ),
+					'pending_moderation' => __( 'Thanks, your review is pending approval.', 'woocommerce' ),
+					'error'              => __( 'Something went wrong, please try again.', 'woocommerce' ),
+				),
+			)
+		);
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php b/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
new file mode 100644
index 00000000000..cd86af8446c
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
@@ -0,0 +1,303 @@
+<?php
+/**
+ * SubmissionHandler class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\OrderReviews;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use WC_Order;
+
+/**
+ * Handles the AJAX submission of the Review Order form.
+ *
+ * One comment per rated row, with per-row outcome reported back so a single
+ * row's failure cannot block the rest. Guests submit with the order key;
+ * logged-in customers must own the order.
+ *
+ * @internal Just for internal use.
+ *
+ * @since 10.8.0
+ */
+class SubmissionHandler {
+
+	/**
+	 * Action name registered with admin-ajax.
+	 */
+	public const ACTION = 'woocommerce_submit_order_reviews';
+
+	/**
+	 * Order meta set when every eligible item has been reviewed by the
+	 * matching author.
+	 */
+	public const COMPLETED_META_KEY = '_wc_review_request_completed_at';
+
+	/**
+	 * Wire the AJAX endpoints.
+	 *
+	 * Auto-called by the WC dependency container after instantiation.
+	 *
+	 * @internal
+	 */
+	final public function init(): void {
+		add_action( 'wp_ajax_' . self::ACTION, array( $this, 'handle' ) );
+		add_action( 'wp_ajax_nopriv_' . self::ACTION, array( $this, 'handle' ) );
+	}
+
+	/**
+	 * Entry point fired by `admin-ajax.php`.
+	 *
+	 * Sends a JSON response and exits.
+	 */
+	public function handle(): void {
+		// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce is checked below.
+		$order_id = isset( $_POST['order_id'] ) ? absint( $_POST['order_id'] ) : 0;
+		$key      = isset( $_POST['key'] ) && is_string( $_POST['key'] ) ? sanitize_text_field( wp_unslash( $_POST['key'] ) ) : '';
+		$nonce    = isset( $_POST['_wcnonce'] ) && is_string( $_POST['_wcnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wcnonce'] ) ) : '';
+		// Row-level fields are sanitized inside process_rows(); the array as a whole only needs unslashing.
+		$rows_in = isset( $_POST['reviews'] ) && is_array( $_POST['reviews'] ) ? wp_unslash( $_POST['reviews'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+		// phpcs:enable WordPress.Security.NonceVerification.Missing
+
+		if ( ! is_string( $nonce ) || ! wp_verify_nonce( $nonce, self::ACTION ) ) {
+			wp_send_json_error( array( 'message' => __( 'Security check failed.', 'woocommerce' ) ), 403 );
+		}
+
+		$order = $order_id ? wc_get_order( $order_id ) : false;
+		if ( ! $order instanceof WC_Order ) {
+			wp_send_json_error( array( 'message' => __( 'Order not found.', 'woocommerce' ) ), 404 );
+		}
+
+		if ( '' === $key || ! hash_equals( $order->get_order_key(), $key ) ) {
+			wp_send_json_error( array( 'message' => __( 'Order not found.', 'woocommerce' ) ), 404 );
+		}
+
+		// Logged-in user must own the order. Guests with the right key still pass.
+		if ( $order->get_customer_id() && is_user_logged_in() && get_current_user_id() !== $order->get_customer_id() ) {
+			wp_send_json_error( array( 'message' => __( 'Order not found.', 'woocommerce' ) ), 404 );
+		}
+
+		// Reuse the same eligibility filter the page-load endpoint uses so the
+		// submit path can never run on an order whose status no longer permits it.
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- documented on Endpoint::is_authorised().
+		$eligible_statuses = (array) apply_filters(
+			'woocommerce_review_order_eligible_statuses',
+			array( OrderStatus::COMPLETED ),
+			$order
+		);
+
+		if ( ! in_array( $order->get_status(), $eligible_statuses, true ) ) {
+			wp_send_json_error( array( 'message' => __( 'Order not found.', 'woocommerce' ) ), 404 );
+		}
+
+		$results = $this->process_rows( $order, $rows_in );
+
+		$this->maybe_mark_order_complete( $order );
+
+		/**
+		 * Fires after the Review Order form has been processed.
+		 *
+		 * @since 10.8.0
+		 *
+		 * @param WC_Order $order   The order.
+		 * @param array    $results Per-row outcomes — see `SubmissionHandler::process_rows()`.
+		 */
+		do_action( 'woocommerce_review_order_submitted', $order, $results );
+
+		wp_send_json_success( array( 'results' => $results ) );
+	}
+
+	/**
+	 * Process the submitted row payload and return per-row outcomes.
+	 *
+	 * @param WC_Order $order  Order being reviewed.
+	 * @param array    $rows_in Raw `$_POST['reviews']` value.
+	 * @return array<int, array{product_id:int, status:string, comment_id?:int, error?:string}>
+	 */
+	private function process_rows( WC_Order $order, array $rows_in ): array {
+		$results      = array();
+		$item_index   = $this->index_order_items( $order );
+		$author_name  = trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() );
+		$author_email = $order->get_billing_email();
+		$author_ip    = $order->get_customer_ip_address();
+		$author_agent = $order->get_customer_user_agent();
+		$require_mod  = (bool) get_option( 'comment_moderation' );
+
+		foreach ( $rows_in as $row_index => $row ) {
+			$row_index = (int) $row_index;
+			$row       = is_array( $row ) ? $row : array();
+
+			$rating = isset( $row['rating'] ) ? (int) $row['rating'] : 0;
+			if ( 0 === $rating ) {
+				// Empty rating means the customer chose to skip this row; allowed.
+				continue;
+			}
+
+			$product_id    = isset( $row['product_id'] ) ? absint( $row['product_id'] ) : 0;
+			$order_item_id = isset( $row['order_item_id'] ) ? absint( $row['order_item_id'] ) : 0;
+			// $rows_in was already unslashed in handle(); avoid double-unslashing.
+			$text = isset( $row['text'] ) && is_string( $row['text'] ) ? trim( wp_kses_post( $row['text'] ) ) : '';
+
+			$result = array(
+				'product_id' => $product_id,
+				'status'     => 'error',
+			);
+
+			if ( $rating < 1 || $rating > 5 ) {
+				$result['error']       = 'invalid_rating';
+				$results[ $row_index ] = $result;
+				continue;
+			}
+
+			if ( ! $product_id || ! $order_item_id || ! isset( $item_index[ $order_item_id ] ) ) {
+				$result['error']       = 'invalid_row';
+				$results[ $row_index ] = $result;
+				continue;
+			}
+
+			$item = $item_index[ $order_item_id ];
+
+			// Variable products: the row template posts the variation id,
+			// while $item->get_product_id() returns the parent. Accept either.
+			$line_product_id   = (int) $item->get_product_id();
+			$line_variation_id = (int) $item->get_variation_id();
+			if ( $product_id !== $line_product_id && $product_id !== $line_variation_id ) {
+				$result['error']       = 'product_mismatch';
+				$results[ $row_index ] = $result;
+				continue;
+			}
+
+			// Reviews always attach to the parent product so they show on the
+			// product page regardless of which variation was bought.
+			$review_post_id = $line_product_id;
+
+			// Only attribute the comment to a WP user when the current request is
+			// authenticated as that user. Guests reaching the page via the order
+			// key are not authenticated, so the comment stays unattributed (0).
+			$customer_id     = (int) $order->get_customer_id();
+			$current_user_id = get_current_user_id();
+			$comment_user_id = ( $current_user_id > 0 && $current_user_id === $customer_id ) ? $current_user_id : 0;
+
+			$comment_data = array(
+				'comment_post_ID'      => $review_post_id,
+				'comment_author'       => '' !== $author_name ? $author_name : __( 'Anonymous', 'woocommerce' ),
+				'comment_author_email' => $author_email,
+				'comment_author_IP'    => $author_ip,
+				'comment_agent'        => $author_agent,
+				'comment_content'      => $text,
+				'comment_type'         => 'review',
+				'comment_approved'     => $require_mod ? 0 : 1,
+				'user_id'              => $comment_user_id,
+			);
+
+			$comment_id = wp_insert_comment( wp_slash( $comment_data ) );
+			if ( ! $comment_id ) {
+				$result['error']       = 'insert_failed';
+				$results[ $row_index ] = $result;
+				continue;
+			}
+
+			add_comment_meta( $comment_id, 'rating', $rating, true );
+			add_comment_meta( $comment_id, 'verified', 1, true );
+
+			$result['comment_id']  = (int) $comment_id;
+			$result['status']      = $require_mod ? 'pending_moderation' : 'ok';
+			$results[ $row_index ] = $result;
+		}//end foreach
+
+		return $results;
+	}
+
+	/**
+	 * Set the completed-at meta when every eligible item has a review by this
+	 * customer (approved or pending moderation), whether posted in this
+	 * submission or an earlier one. Spam/trash comments are excluded.
+	 *
+	 * @param WC_Order $order Order being reviewed.
+	 */
+	private function maybe_mark_order_complete( WC_Order $order ): void {
+		// Recording the moment the order first became fully reviewed; never overwrite.
+		if ( $order->get_meta( self::COMPLETED_META_KEY ) ) {
+			return;
+		}
+
+		$customer_email = $order->get_billing_email();
+		if ( '' === $customer_email ) {
+			return;
+		}
+
+		// Build the same eligible-row set the page uses, then count required
+		// reviews per parent product. Same product appearing on N rows needs
+		// N reviews, not 1.
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- documented at the page-template invocation site.
+		$eligible_items = (array) apply_filters( 'woocommerce_review_order_eligible_items', $order->get_items(), $order );
+
+		$required_reviews = array();
+		foreach ( $eligible_items as $item ) {
+			if ( ! $item instanceof \WC_Order_Item_Product ) {
+				continue;
+			}
+			$product_id = (int) $item->get_product_id();
+			if ( $product_id > 0 ) {
+				$required_reviews[ $product_id ] = ( $required_reviews[ $product_id ] ?? 0 ) + 1;
+			}
+		}
+
+		if ( empty( $required_reviews ) ) {
+			return;
+		}
+
+		// Single grouped lookup, fetching the comment objects directly so we
+		// can read comment_post_ID without a follow-up query per row. Limit
+		// to approved + pending-moderation so spam/trash never count as
+		// completion. number=>0 disables the default 20-row cap so this still
+		// works for orders with many reviewable items.
+		$comments = get_comments(
+			array(
+				'post__in'     => array_keys( $required_reviews ),
+				'author_email' => $customer_email,
+				'type'         => 'review',
+				'status'       => array( 'approve', 'hold' ),
+				'number'       => 0,
+			)
+		);
+
+		if ( ! is_array( $comments ) || empty( $comments ) ) {
+			return;
+		}
+
+		$review_counts = array();
+		foreach ( $comments as $comment ) {
+			if ( $comment instanceof \WP_Comment ) {
+				$post_id                   = (int) $comment->comment_post_ID;
+				$review_counts[ $post_id ] = ( $review_counts[ $post_id ] ?? 0 ) + 1;
+			}
+		}
+
+		foreach ( $required_reviews as $product_id => $required ) {
+			if ( ( $review_counts[ $product_id ] ?? 0 ) < $required ) {
+				return;
+			}
+		}
+
+		$order->update_meta_data( self::COMPLETED_META_KEY, (string) time() );
+		$order->save();
+	}
+
+	/**
+	 * Map order_item_id => `WC_Order_Item_Product` for fast row lookup.
+	 *
+	 * @param WC_Order $order Order being reviewed.
+	 * @return array<int, \WC_Order_Item_Product>
+	 */
+	private function index_order_items( WC_Order $order ): array {
+		$index = array();
+		foreach ( $order->get_items() as $item ) {
+			if ( $item instanceof \WC_Order_Item_Product ) {
+				$index[ $item->get_id() ] = $item;
+			}
+		}
+		return $index;
+	}
+}
diff --git a/plugins/woocommerce/templates/order/customer-review-order.php b/plugins/woocommerce/templates/order/customer-review-order.php
index a634755b026..3ac0f4108a4 100644
--- a/plugins/woocommerce/templates/order/customer-review-order.php
+++ b/plugins/woocommerce/templates/order/customer-review-order.php
@@ -111,8 +111,11 @@ $order_key = (string) $order->get_order_key();
 		<form
 			class="woocommerce-review-order__form"
 			method="post"
+			action="<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>"
+			data-ajax-url="<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>"
 			novalidate
 		>
+			<input type="hidden" name="action" value="<?php echo esc_attr( 'woocommerce_submit_order_reviews' ); ?>" />
 			<input type="hidden" name="order_id" value="<?php echo esc_attr( (string) $order->get_id() ); ?>" />
 			<input type="hidden" name="key" value="<?php echo esc_attr( $order_key ); ?>" />
 			<?php wp_nonce_field( 'woocommerce_submit_order_reviews', '_wcnonce' ); ?>
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php
new file mode 100644
index 00000000000..97f4c15ce73
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php
@@ -0,0 +1,505 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\OrderReviews;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\OrderReviews\SubmissionHandler;
+use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
+use WC_Helper_Product;
+use WC_Order;
+use WC_Unit_Test_Case;
+use WPAjaxDieContinueException;
+
+/**
+ * Tests for the Review Order submission handler.
+ */
+class SubmissionHandlerTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Reset state between tests.
+	 */
+	public function tearDown(): void {
+		$_POST = array();
+		update_option( 'comment_moderation', '0' );
+		remove_all_filters( 'woocommerce_review_order_submitted' );
+		remove_all_filters( 'woocommerce_review_order_eligible_statuses' );
+		remove_all_filters( 'wp_die_ajax_handler' );
+		remove_all_filters( 'wp_send_json_handler' );
+		remove_all_filters( 'wp_doing_ajax' );
+		parent::tearDown();
+	}
+
+	/**
+	 * Build a completed order with the given number of products.
+	 *
+	 * @param int $product_count How many products to attach.
+	 * @return array{order:WC_Order, product_ids:int[], item_ids:int[]}
+	 */
+	private function make_order( int $product_count = 1 ): array {
+		$order = OrderHelper::create_order();
+		// Wipe the default item.
+		foreach ( $order->get_items() as $item ) {
+			$order->remove_item( $item->get_id() );
+		}
+		$order->set_billing_first_name( 'Jane' );
+		$order->set_billing_last_name( 'Doe' );
+		$order->set_billing_email( 'jane@example.test' );
+		$order->set_status( OrderStatus::COMPLETED );
+
+		$product_ids = array();
+		for ( $i = 0; $i < $product_count; $i++ ) {
+			$product       = WC_Helper_Product::create_simple_product();
+			$product_ids[] = $product->get_id();
+			$order->add_product( $product, 1 );
+		}
+		$order->save();
+
+		$item_ids = array();
+		foreach ( $order->get_items() as $item ) {
+			$item_ids[] = $item->get_id();
+		}
+
+		return array(
+			'order'       => $order,
+			'product_ids' => $product_ids,
+			'item_ids'    => $item_ids,
+		);
+	}
+
+	/**
+	 * Invoke the handler and capture the JSON it would have sent.
+	 *
+	 * @return array{success:bool, data:mixed, status:int}
+	 */
+	private function dispatch(): array {
+		$response = array(
+			'success' => false,
+			'data'    => null,
+			'status'  => 200,
+		);
+
+		$capture = static function ( $payload, $status ) use ( &$response ) {
+			$response['success'] = ! empty( $payload['success'] );
+			$response['data']    = $payload['data'] ?? null;
+			$response['status']  = (int) ( $status ?? 200 );
+		};
+
+		add_filter( 'wp_die_ajax_handler', static fn() => static fn() => null );
+
+		add_filter(
+			'wp_send_json_handler',
+			static function () use ( $capture ) {
+				return $capture;
+			}
+		);
+
+		add_filter(
+			'wp_doing_ajax',
+			static function () {
+				return true;
+			}
+		);
+
+		// `wp_send_json_*` always calls `wp_die`, but we can short-circuit
+		// the JSON output by hooking the early `wp_die_ajax_handler`.
+		// Easier: just call the handler and trust it sends headers; capture
+		// via output buffering.
+		ob_start();
+		$handler = new SubmissionHandler();
+		try {
+			$handler->handle();
+		} catch ( WPAjaxDieContinueException $e ) {
+			// Expected: wp_send_json_* calls wp_die().
+			unset( $e );
+		}
+		$body = (string) ob_get_clean();
+
+		$decoded = json_decode( $body, true );
+		if ( is_array( $decoded ) ) {
+			$response['success'] = ! empty( $decoded['success'] );
+			$response['data']    = $decoded['data'] ?? null;
+		}
+		return $response;
+	}
+
+	/**
+	 * @testdox Handler rejects requests with a missing or bad nonce.
+	 */
+	public function test_rejects_bad_nonce(): void {
+		$built = $this->make_order( 1 );
+		/** @var WC_Order $order */
+		$order = $built['order'];
+
+		$_POST = array(
+			'order_id' => $order->get_id(),
+			'key'      => $order->get_order_key(),
+			'_wcnonce' => 'not-the-right-nonce',
+		);
+
+		$response = $this->dispatch();
+
+		$this->assertFalse( $response['success'] );
+	}
+
+	/**
+	 * @testdox Handler rejects mismatched order keys.
+	 */
+	public function test_rejects_bad_key(): void {
+		$built = $this->make_order( 1 );
+		/** @var WC_Order $order */
+		$order = $built['order'];
+
+		$_POST = array(
+			'order_id' => $order->get_id(),
+			'key'      => 'wc_order_NOPE',
+			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+		);
+
+		$response = $this->dispatch();
+
+		$this->assertFalse( $response['success'] );
+	}
+
+	/**
+	 * @testdox A valid submission inserts a comment with rating + verified meta.
+	 */
+	public function test_inserts_review_with_meta(): void {
+		$built = $this->make_order( 1 );
+		/** @var WC_Order $order */
+		$order      = $built['order'];
+		$product_id = $built['product_ids'][0];
+		$item_id    = $built['item_ids'][0];
+
+		$_POST = array(
+			'order_id' => $order->get_id(),
+			'key'      => $order->get_order_key(),
+			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+			'reviews'  => array(
+				array(
+					'product_id'    => $product_id,
+					'order_item_id' => $item_id,
+					'rating'        => 5,
+					'text'          => 'Excellent product, highly recommended.',
+				),
+			),
+		);
+
+		$response = $this->dispatch();
+		$this->assertTrue( $response['success'] );
+		$this->assertIsArray( $response['data'] );
+		$this->assertArrayHasKey( 'results', $response['data'] );
+		$results = $response['data']['results'];
+		$this->assertCount( 1, $results );
+		$row = reset( $results );
+		$this->assertSame( 'ok', $row['status'] );
+		$this->assertArrayHasKey( 'comment_id', $row );
+
+		$comment = get_comment( $row['comment_id'] );
+		$this->assertNotNull( $comment );
+		$this->assertSame( (int) $product_id, (int) $comment->comment_post_ID );
+		$this->assertSame( 'review', $comment->comment_type );
+		$this->assertSame( '5', get_comment_meta( $row['comment_id'], 'rating', true ) );
+		$this->assertSame( '1', get_comment_meta( $row['comment_id'], 'verified', true ) );
+	}
+
+	/**
+	 * @testdox Rows with no rating are skipped silently.
+	 */
+	public function test_skips_rows_without_rating(): void {
+		$built = $this->make_order( 2 );
+		/** @var WC_Order $order */
+		$order = $built['order'];
+
+		$_POST = array(
+			'order_id' => $order->get_id(),
+			'key'      => $order->get_order_key(),
+			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+			'reviews'  => array(
+				array(
+					'product_id'    => $built['product_ids'][0],
+					'order_item_id' => $built['item_ids'][0],
+					'rating'        => 4,
+					'text'          => 'Great.',
+				),
+				array(
+					'product_id'    => $built['product_ids'][1],
+					'order_item_id' => $built['item_ids'][1],
+					'rating'        => 0,
+					'text'          => '',
+				),
+			),
+		);
+
+		$response = $this->dispatch();
+		$this->assertTrue( $response['success'] );
+		$results = $response['data']['results'];
+		$this->assertCount( 1, $results, 'Skipped row should not appear in the results.' );
+	}
+
+	/**
+	 * @testdox When comment_moderation is enabled, rows return pending_moderation.
+	 */
+	public function test_pending_moderation(): void {
+		update_option( 'comment_moderation', '1' );
+
+		$built      = $this->make_order( 1 );
+		$order      = $built['order'];
+		$product_id = $built['product_ids'][0];
+		$item_id    = $built['item_ids'][0];
+
+		$_POST = array(
+			'order_id' => $order->get_id(),
+			'key'      => $order->get_order_key(),
+			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+			'reviews'  => array(
+				array(
+					'product_id'    => $product_id,
+					'order_item_id' => $item_id,
+					'rating'        => 4,
+					'text'          => 'Pending text.',
+				),
+			),
+		);
+
+		$response = $this->dispatch();
+		$results  = $response['data']['results'];
+		$row      = reset( $results );
+		$this->assertSame( 'pending_moderation', $row['status'] );
+
+		$comment = get_comment( $row['comment_id'] );
+		$this->assertSame( '0', $comment->comment_approved );
+	}
+
+	/**
+	 * @testdox Rows referencing a product not on the order fail per-row, others succeed.
+	 */
+	public function test_per_row_isolation(): void {
+		$built = $this->make_order( 1 );
+		$order = $built['order'];
+
+		$_POST = array(
+			'order_id' => $order->get_id(),
+			'key'      => $order->get_order_key(),
+			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+			'reviews'  => array(
+				array(
+					'product_id'    => $built['product_ids'][0],
+					'order_item_id' => $built['item_ids'][0],
+					'rating'        => 5,
+				),
+				array(
+					'product_id'    => 999999,
+					'order_item_id' => 999999,
+					'rating'        => 5,
+				),
+			),
+		);
+
+		$response = $this->dispatch();
+		$results  = $response['data']['results'];
+
+		$this->assertCount( 2, $results );
+		$ok_count    = 0;
+		$error_count = 0;
+		foreach ( $results as $row ) {
+			if ( 'ok' === $row['status'] ) {
+				++$ok_count;
+			} elseif ( 'error' === $row['status'] ) {
+				++$error_count;
+			}
+		}
+		$this->assertSame( 1, $ok_count );
+		$this->assertSame( 1, $error_count );
+	}
+
+	/**
+	 * @testdox Out-of-range ratings surface as a per-row error (invalid_rating).
+	 */
+	public function test_invalid_rating_returns_error(): void {
+		$built = $this->make_order( 1 );
+		$order = $built['order'];
+
+		$_POST = array(
+			'order_id' => $order->get_id(),
+			'key'      => $order->get_order_key(),
+			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+			'reviews'  => array(
+				array(
+					'product_id'    => $built['product_ids'][0],
+					'order_item_id' => $built['item_ids'][0],
+					'rating'        => 7,
+				),
+			),
+		);
+
+		$response = $this->dispatch();
+		$row      = $response['data']['results'][0];
+
+		$this->assertSame( 'error', $row['status'] );
+		$this->assertSame( 'invalid_rating', $row['error'] );
+	}
+
+	/**
+	 * @testdox Submitting a product_id that doesn't match the order item surfaces product_mismatch.
+	 */
+	public function test_product_mismatch_returns_error(): void {
+		$built = $this->make_order( 1 );
+		$order = $built['order'];
+
+		$_POST = array(
+			'order_id' => $order->get_id(),
+			'key'      => $order->get_order_key(),
+			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+			'reviews'  => array(
+				array(
+					'product_id'    => $built['product_ids'][0] + 99999,
+					'order_item_id' => $built['item_ids'][0],
+					'rating'        => 4,
+				),
+			),
+		);
+
+		$response = $this->dispatch();
+		$row      = $response['data']['results'][0];
+
+		$this->assertSame( 'error', $row['status'] );
+		$this->assertSame( 'product_mismatch', $row['error'] );
+	}
+
+	/**
+	 * @testdox Order completed-at meta is set when every item has been reviewed.
+	 */
+	public function test_marks_order_complete_when_every_item_reviewed(): void {
+		$built = $this->make_order( 2 );
+		$order = $built['order'];
+
+		$_POST = array(
+			'order_id' => $order->get_id(),
+			'key'      => $order->get_order_key(),
+			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+			'reviews'  => array(
+				array(
+					'product_id'    => $built['product_ids'][0],
+					'order_item_id' => $built['item_ids'][0],
+					'rating'        => 5,
+				),
+				array(
+					'product_id'    => $built['product_ids'][1],
+					'order_item_id' => $built['item_ids'][1],
+					'rating'        => 4,
+				),
+			),
+		);
+
+		$response = $this->dispatch();
+		$this->assertTrue( $response['success'] );
+
+		$fresh = wc_get_order( $order->get_id() );
+		$this->assertNotEmpty( $fresh->get_meta( SubmissionHandler::COMPLETED_META_KEY ) );
+	}
+
+	/**
+	 * @testdox Order completed-at meta is NOT set when some items are still unreviewed.
+	 */
+	public function test_does_not_mark_complete_when_one_item_unreviewed(): void {
+		$built = $this->make_order( 2 );
+		$order = $built['order'];
+
+		$_POST = array(
+			'order_id' => $order->get_id(),
+			'key'      => $order->get_order_key(),
+			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+			'reviews'  => array(
+				array(
+					'product_id'    => $built['product_ids'][0],
+					'order_item_id' => $built['item_ids'][0],
+					'rating'        => 5,
+				),
+				// Second product intentionally omitted.
+			),
+		);
+
+		$this->dispatch();
+
+		$fresh = wc_get_order( $order->get_id() );
+		$this->assertEmpty( $fresh->get_meta( SubmissionHandler::COMPLETED_META_KEY ) );
+	}
+
+	/**
+	 * @testdox A successful submission fires the woocommerce_review_order_submitted action with order + per-row results.
+	 */
+	public function test_fires_review_order_submitted_action(): void {
+		$built      = $this->make_order( 1 );
+		$order      = $built['order'];
+		$product_id = $built['product_ids'][0];
+		$item_id    = $built['item_ids'][0];
+
+		$captured = array(
+			'order'   => null,
+			'results' => null,
+			'calls'   => 0,
+		);
+
+		add_action(
+			'woocommerce_review_order_submitted',
+			static function ( $order_arg, $results_arg ) use ( &$captured ) {
+				$captured['order']   = $order_arg;
+				$captured['results'] = $results_arg;
+				++$captured['calls'];
+			},
+			10,
+			2
+		);
+
+		$_POST = array(
+			'order_id' => $order->get_id(),
+			'key'      => $order->get_order_key(),
+			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+			'reviews'  => array(
+				array(
+					'product_id'    => $product_id,
+					'order_item_id' => $item_id,
+					'rating'        => 4,
+				),
+			),
+		);
+
+		$this->dispatch();
+
+		$this->assertSame( 1, $captured['calls'], 'Action should fire exactly once per submission.' );
+		$this->assertInstanceOf( WC_Order::class, $captured['order'] );
+		$this->assertSame( $order->get_id(), $captured['order']->get_id() );
+		$this->assertIsArray( $captured['results'] );
+		$this->assertCount( 1, $captured['results'] );
+		$row = reset( $captured['results'] );
+		$this->assertSame( 'ok', $row['status'] );
+	}
+
+	/**
+	 * @testdox Submissions are rejected when the order's status is no longer eligible.
+	 */
+	public function test_rejects_when_order_status_ineligible(): void {
+		$built = $this->make_order( 1 );
+		$order = $built['order'];
+		$order->set_status( OrderStatus::PROCESSING );
+		$order->save();
+
+		$_POST = array(
+			'order_id' => $order->get_id(),
+			'key'      => $order->get_order_key(),
+			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+			'reviews'  => array(
+				array(
+					'product_id'    => $built['product_ids'][0],
+					'order_item_id' => $built['item_ids'][0],
+					'rating'        => 5,
+				),
+			),
+		);
+
+		$response = $this->dispatch();
+
+		$this->assertFalse( $response['success'] );
+	}
+}