Commit aa60492fba1 for woocommerce
commit aa60492fba1b30550004974eaec21cc140582d17
Author: Miguel Gasca <miguel.gasca@automattic.com>
Date: Tue Nov 3 19:26:51 2026 +0300
Fix duplicate orders when a shopper retries checkout after a post-payment failure (#68749)
A gateway can move an order on and then fail inside that status change,
for example when a mail plugin dies in woocommerce_mail_callback. The
shopper sees an error with the cart still full, and submitting again
created and charged a second order.
Both checkouts now send that retry to the order already placed. The
shortcode checkout recovers the first attempt as the Store API does, a
new order_sent_to_gateway session key survives payment_complete(), and
a repeat submit is answered before validation, so its hooks no longer
run. The shared code lives in Internal\Checkout\PaymentRecovery.
diff --git a/plugins/woocommerce/changelog/fix-42223-classic-checkout-repeat-submit b/plugins/woocommerce/changelog/fix-42223-classic-checkout-repeat-submit
new file mode 100644
index 00000000000..8cc9c79d4c1
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-42223-classic-checkout-repeat-submit
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Stop creating a duplicate order when a customer retries a checkout that already went through but showed an error.
diff --git a/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md b/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
index 66485a369f1..d1e6e08b2ca 100644
--- a/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
+++ b/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
@@ -2046,7 +2046,7 @@ apply_filters( 'woocommerce_valid_order_statuses_for_payment', array $valid_orde
### Description
-The same filter WC_Order::needs_payment() applies. A status a site declares payable counts as awaiting payment here too, so a failure while the order is in it is reported rather than recovered as one that took payment.
+The same filter WC_Order::needs_payment() applies, so a zero-total order in a status the site declares payable can be resumed.
### Parameters
@@ -2057,7 +2057,6 @@ The same filter WC_Order::needs_payment() applies. A status a site declares paya
### Source
-- [StoreApi/Utilities/CheckoutTrait.php](../../../../../../src/StoreApi/Utilities/CheckoutTrait.php)
- [StoreApi/Utilities/DraftOrderTrait.php](../../../../../../src/StoreApi/Utilities/DraftOrderTrait.php)
---
diff --git a/plugins/woocommerce/includes/class-wc-cart-session.php b/plugins/woocommerce/includes/class-wc-cart-session.php
index df2ba47ea56..18642a0f997 100644
--- a/plugins/woocommerce/includes/class-wc-cart-session.php
+++ b/plugins/woocommerce/includes/class-wc-cart-session.php
@@ -8,6 +8,7 @@
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Internal\Checkout\PaymentRecovery;
if ( ! defined( 'ABSPATH' ) ) {
exit;
@@ -379,6 +380,7 @@ final class WC_Cart_Session {
$wc_session->set( 'coupon_discount_tax_totals', null );
$wc_session->set( 'removed_cart_contents', null );
$wc_session->set( 'order_awaiting_payment', null );
+ $wc_session->set( PaymentRecovery::ORDER_SENT_TO_GATEWAY, null );
$wc_session->set( 'store_api_draft_order', null );
$wc_session->set( 'shipping_method_counts', null );
$wc_session->set( 'previous_shipping_methods', null );
diff --git a/plugins/woocommerce/includes/class-wc-checkout.php b/plugins/woocommerce/includes/class-wc-checkout.php
index 7db68b20216..64c9c291eb0 100644
--- a/plugins/woocommerce/includes/class-wc-checkout.php
+++ b/plugins/woocommerce/includes/class-wc-checkout.php
@@ -12,6 +12,7 @@ use Automattic\WooCommerce\Enums\OrderItemType;
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Enums\ProductTaxStatus;
use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Internal\Checkout\PaymentRecovery;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
use Automattic\WooCommerce\Internal\ProductVariations\SelectedVariationName;
use Automattic\WooCommerce\Internal\Tax\TaxRateDataStore;
@@ -36,6 +37,13 @@ class WC_Checkout {
'shipping_tax',
);
+ /**
+ * Error code create_order() returns when the submit repeats one for an order that moved past payment.
+ *
+ * @var string
+ */
+ private const ORDER_ALREADY_PLACED_ERROR = 'checkout-order-already-placed';
+
/**
* The single instance of the class.
*
@@ -444,6 +452,17 @@ class WC_Checkout {
$available_gateways = WC()->payment_gateways->get_available_payment_gateways();
$order = $order_id ? wc_get_order( $order_id ) : null;
+ // The gateway moved this order on but something died before the cart was
+ // emptied, so this submit is a repeat: a new order would charge the shopper twice.
+ $session_order = PaymentRecovery::get_session_order_moved_past_payment( $order_id );
+ if ( $session_order ) {
+ return new WP_Error(
+ self::ORDER_ALREADY_PLACED_ERROR,
+ __( 'This order has already been placed.', 'woocommerce' ),
+ array( 'order_id' => $session_order->get_id() )
+ );
+ }
+
/**
* If there is an order pending payment, we can resume it here so
* long as it has not changed. If the order has changed, i.e.
@@ -1412,6 +1431,7 @@ class WC_Checkout {
// Store Order ID in session, so it can be re-used after payment failure.
WC()->session->set( 'order_awaiting_payment', $order_id );
+ PaymentRecovery::remember_order_sent_to_gateway( (int) $order_id );
// We save the session early because if the payment gateway hangs
// the request will never finish, thus the session data will never be saved,
@@ -1494,6 +1514,77 @@ class WC_Checkout {
);
}
+ /**
+ * Answer a repeat submit for an order that already moved past payment.
+ *
+ * The shopper is sent to the order received page, where wc_clear_cart_after_payment()
+ * empties the cart the gateway left behind, instead of being charged for a second order.
+ *
+ * @since 11.4.0
+ * @param WC_Order $order The order the session was awaiting payment for.
+ */
+ protected function send_repeat_submit_response( WC_Order $order ): void {
+ PaymentRecovery::record_repeat_submit( $order );
+
+ wc_log_order_step(
+ '[Shortcode #6C] Repeat submit for an order that moved past payment, sending to the order received page',
+ array(
+ 'order_object' => $order,
+ 'redirected' => ! wp_doing_ajax() ? 'yes' : 'no',
+ ),
+ true
+ );
+
+ $this->send_order_received_response( $order );
+ }
+
+ /**
+ * Record a failure raised after the gateway moved the order on and send the shopper to the order received page.
+ *
+ * Reporting a failure would send them back to place the order again, and every retry
+ * pays for another order. PaymentRecovery empties the cart rather than leaving it to the
+ * received page: after payment_complete() nothing in the session points at the order, so
+ * if the shopper never lands there, a submit from the still-open form would build a new one.
+ *
+ * @since 11.4.0
+ * @param WC_Order $order The order, re-read after the failure.
+ * @param Throwable $error The failure raised after the gateway ran.
+ */
+ protected function recover_order_that_moved_past_payment( WC_Order $order, Throwable $error ): void {
+ PaymentRecovery::record_failure_after_payment( $order, $error, 'checkout' );
+
+ wc_log_order_step(
+ '[Shortcode #6D] Order moved past payment before a failure, sending to the order received page',
+ array(
+ 'order_object' => $order,
+ 'error' => get_class( $error ) . ': ' . $error->getMessage(),
+ 'redirected' => ! wp_doing_ajax() ? 'yes' : 'no',
+ ),
+ true
+ );
+
+ $this->send_order_received_response( $order );
+ }
+
+ /**
+ * End the request by sending the shopper to the order received page: a redirect for the form post, JSON for the checkout JS.
+ *
+ * @param WC_Order $order Order object.
+ */
+ private function send_order_received_response( WC_Order $order ): void {
+ if ( ! wp_doing_ajax() ) {
+ wp_safe_redirect( $order->get_checkout_order_received_url() );
+ exit;
+ }
+
+ wp_send_json(
+ array(
+ 'result' => 'success',
+ 'redirect' => $order->get_checkout_order_received_url(),
+ )
+ );
+ }
+
/**
* Create a new customer account if needed.
*
@@ -1620,6 +1711,7 @@ class WC_Checkout {
* Process the checkout after the confirm order button is pressed.
*
* @throws Exception When validation fails.
+ * @throws Throwable When a gateway fails before it moved the order on; only an Error escapes, as before, since an Exception is caught below.
*/
public function process_checkout() {
try {
@@ -1668,6 +1760,13 @@ class WC_Checkout {
wc_log_order_step( '[Shortcode #2] Session updated with checkout data and totals calculated' );
+ // Answer a repeat submit before validation: the first order may have taken the last
+ // units, and the stock check would then turn the shopper away from an order they placed.
+ $session_order = PaymentRecovery::get_session_order_moved_past_payment( absint( WC()->session->get( 'order_awaiting_payment' ) ) );
+ if ( $session_order ) {
+ $this->send_repeat_submit_response( $session_order );
+ }
+
// Validate posted data and cart items before proceeding.
$this->validate_checkout( $posted_data, $errors );
@@ -1687,6 +1786,14 @@ class WC_Checkout {
$order_id = $this->create_order( $posted_data );
$order = wc_get_order( $order_id );
+ if ( is_wp_error( $order_id ) && self::ORDER_ALREADY_PLACED_ERROR === $order_id->get_error_code() ) {
+ $session_order = wc_get_order( $order_id->get_error_data()['order_id'] ?? 0 );
+
+ if ( $session_order instanceof WC_Order ) {
+ $this->send_repeat_submit_response( $session_order );
+ }
+ }
+
if ( is_wp_error( $order_id ) ) {
throw new Exception( $order_id->get_error_message() );
}
@@ -1730,8 +1837,23 @@ class WC_Checkout {
*/
if ( apply_filters( 'woocommerce_cart_needs_payment', $order->needs_payment(), WC()->cart ) ) {
- $this->process_order_payment( $order_id, $posted_data['payment_method'] );
+ try {
+ $this->process_order_payment( $order_id, $posted_data['payment_method'] );
+ } catch ( Throwable $e ) {
+ // The gateway may have moved the order on already, say when a post-payment
+ // integration throws inside the status transition. Re-read it: the gateway
+ // changed the status on its own instance, so $order is stale.
+ $refreshed_order = wc_get_order( $order_id );
+
+ if ( ! $refreshed_order instanceof WC_Order || ! PaymentRecovery::order_moved_past_payment( $refreshed_order ) ) {
+ throw $e;
+ }
+
+ $this->recover_order_that_moved_past_payment( $refreshed_order, $e );
+ }
} else {
+ // No gateway runs, but payment_complete() still runs the status change a plugin can die in.
+ PaymentRecovery::remember_order_sent_to_gateway( (int) $order_id );
$this->process_order_without_payment( $order_id );
}
}
diff --git a/plugins/woocommerce/includes/wc-cart-functions.php b/plugins/woocommerce/includes/wc-cart-functions.php
index dedd1d234ac..b13e195e84c 100644
--- a/plugins/woocommerce/includes/wc-cart-functions.php
+++ b/plugins/woocommerce/includes/wc-cart-functions.php
@@ -12,6 +12,7 @@ use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Internal\Checkout\PaymentRecovery;
use Automattic\WooCommerce\StoreApi\Utilities\LocalPickupUtils;
defined( 'ABSPATH' ) || exit;
@@ -203,8 +204,16 @@ function wc_clear_cart_after_payment() {
}
// If the order is awaiting payment, and we haven't already decided to clear the cart, check the order status.
- if ( is_object( WC()->session ) && WC()->session->order_awaiting_payment > 0 && ! $should_clear_cart_after_payment ) {
- $order = wc_get_order( WC()->session->order_awaiting_payment );
+ $session_order_id = 0;
+ if ( is_object( WC()->session ) ) {
+ // The order sent to the gateway comes first: payment_complete() clears order_awaiting_payment
+ // before the status change, so after a request dies there it is the only pointer left.
+ $session_order_id = absint( WC()->session->get( PaymentRecovery::ORDER_SENT_TO_GATEWAY ) );
+ $session_order_id = $session_order_id ? $session_order_id : absint( WC()->session->order_awaiting_payment );
+ }
+
+ if ( $session_order_id > 0 && ! $should_clear_cart_after_payment ) {
+ $order = wc_get_order( $session_order_id );
if ( $order instanceof WC_Order && $order->get_id() > 0 ) {
// If the order status is neither pending, failed, nor cancelled, the order must have gone through.
diff --git a/plugins/woocommerce/src/Internal/Checkout/PaymentRecovery.php b/plugins/woocommerce/src/Internal/Checkout/PaymentRecovery.php
new file mode 100644
index 00000000000..9166bb9b2da
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Checkout/PaymentRecovery.php
@@ -0,0 +1,190 @@
+<?php
+/**
+ * PaymentRecovery class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Checkout;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Throwable;
+use WC_Order;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * What both checkouts need when a failure follows the gateway: whether the order it left behind
+ * moved past payment, and how to record the failure without sending the shopper back to pay again.
+ *
+ * The shortcode checkout (WC_Checkout) and the Store API checkout answer such a failure
+ * differently, one with a redirect or JSON and the other with a PaymentResult, but they judge the
+ * order and record the failure the same way, so that part lives here.
+ *
+ * @internal For WooCommerce core use only.
+ */
+final class PaymentRecovery {
+
+ /**
+ * Session key holding the order last handed to a gateway, or completed without one when it was free.
+ *
+ * Unlike order_awaiting_payment, which WC_Order::payment_complete() clears before the status
+ * change, this survives a request that dies inside that change, so a repeat submit can still
+ * find the order. Emptying the cart clears it.
+ *
+ * @var string
+ */
+ public const ORDER_SENT_TO_GATEWAY = 'order_sent_to_gateway';
+
+ /**
+ * Remembers the order a checkout is about to hand to the gateway.
+ *
+ * @since 11.4.0
+ *
+ * @param int $order_id Order ID.
+ */
+ public static function remember_order_sent_to_gateway( int $order_id ): void {
+ if ( WC()->session ) {
+ WC()->session->set( self::ORDER_SENT_TO_GATEWAY, $order_id );
+ }
+ }
+
+ /**
+ * The session's order, when it moved past payment and the cart still holds it.
+ *
+ * That combination means a submit repeats one the gateway already took: the gateway moved the
+ * order on but the request died before the cart was emptied.
+ *
+ * @since 11.4.0
+ *
+ * @param int $fallback_order_id Order to use when nothing was remembered, as in a session written before the key existed.
+ * @return WC_Order|null
+ */
+ public static function get_session_order_moved_past_payment( int $fallback_order_id = 0 ): ?WC_Order {
+ if ( ! WC()->session || ! WC()->cart ) {
+ return null;
+ }
+
+ $order_id = absint( WC()->session->get( self::ORDER_SENT_TO_GATEWAY ) );
+ $order = wc_get_order( $order_id ? $order_id : $fallback_order_id );
+
+ if ( ! $order instanceof WC_Order || ! $order->has_cart_hash( WC()->cart->get_cart_hash() ) ) {
+ return null;
+ }
+
+ return self::order_moved_past_payment( $order ) ? $order : null;
+ }
+
+ /**
+ * Notes on the order that a repeat submit was answered with its order received page.
+ *
+ * @since 11.4.0
+ *
+ * @param WC_Order $order The order the gateway already moved past payment.
+ */
+ public static function record_repeat_submit( WC_Order $order ): void {
+ $order->add_order_note( __( 'The checkout form was submitted again for this order after the payment step. No second order was created; the customer was sent to the order received page.', 'woocommerce' ) );
+ }
+
+ /**
+ * Reads the order again, so a status the gateway set on its own instance is seen.
+ *
+ * @since 11.4.0
+ *
+ * @param WC_Order $order Order object, possibly stale.
+ * @return WC_Order The stored order, or the given one when it can no longer be read.
+ */
+ public static function refresh_order( WC_Order $order ): WC_Order {
+ $refreshed_order = wc_get_order( $order->get_id() );
+
+ return $refreshed_order instanceof WC_Order ? $refreshed_order : $order;
+ }
+
+ /**
+ * Whether the order has moved beyond the point of awaiting payment.
+ *
+ * An order reaches a gateway awaiting payment or as a draft, so any other status means
+ * something moved it on. This is about the status, not the money: an order parked on-hold
+ * for review counts, because sending the shopper back to place it again would be wrong.
+ *
+ * The statuses a site declares payable count as awaiting payment, so a custom status an
+ * extension parks the order in before the gateway runs is not mistaken for a paid one.
+ * Not needs_payment(): that folds in the order total, and a fully discounted order that
+ * failed would then keep its stock and coupon holds.
+ *
+ * @since 11.4.0
+ *
+ * @param WC_Order $order Order object.
+ * @return bool
+ */
+ public static function order_moved_past_payment( WC_Order $order ): bool {
+ /**
+ * This filter is documented in woocommerce/includes/class-wc-order.php
+ *
+ * @since 2.7.0
+ */
+ $payable_statuses = apply_filters( 'woocommerce_valid_order_statuses_for_payment', array( OrderStatus::PENDING, OrderStatus::FAILED ), $order );
+
+ return ! $order->has_status(
+ array_merge(
+ (array) $payable_statuses,
+ array(
+ OrderStatus::CHECKOUT_DRAFT,
+ OrderStatus::CANCELLED,
+ OrderStatus::REFUNDED,
+ OrderStatus::TRASH,
+ )
+ )
+ );
+ }
+
+ /**
+ * Records a failure raised after the gateway moved the order on, and clears the cart it left.
+ *
+ * The caller still has to answer the shopper with the order received page: this only writes
+ * down what happened, since neither checkout reports the failure to the shopper any more.
+ *
+ * @since 11.4.0
+ *
+ * @param WC_Order $order Order object.
+ * @param Throwable $error The failure raised after the gateway moved the order on.
+ * @param string $log_source Log source to file the failure under.
+ */
+ public static function record_failure_after_payment( WC_Order $order, Throwable $error, string $log_source ): void {
+ /*
+ * The failure goes in the message, not the context: the file handler renders context with
+ * wp_json_encode(), and neither WC_Order nor Throwable exposes public properties, so the
+ * objects alone would write an empty {}. This is now the only log of the error.
+ */
+ wc_get_logger()->error(
+ sprintf(
+ 'Checkout for order #%1$d failed after it moved past the payment step: %2$s: %3$s in %4$s:%5$d',
+ $order->get_id(),
+ get_class( $error ),
+ $error->getMessage(),
+ $error->getFile(),
+ $error->getLine()
+ ),
+ array( 'source' => $log_source )
+ );
+
+ $order->add_order_note(
+ sprintf(
+ /* translators: %s: the error that was raised after the order moved past the payment step. */
+ __( 'Checkout could not be completed after the order moved past the payment step. The order keeps the status the gateway set. Error: %s', 'woocommerce' ),
+ wp_strip_all_tags( $error->getMessage() )
+ )
+ );
+
+ /*
+ * The gateway never reached the point where it empties the cart, so do it here: otherwise
+ * the shopper lands on the confirmation with the order still in their cart and can place
+ * it again. Only when the cart still belongs to this order, since pay-for-order and a
+ * second tab both reach here with an unrelated cart. Same guard as
+ * wc_clear_cart_after_payment().
+ */
+ if ( WC()->cart && $order->has_cart_hash( WC()->cart->get_cart_hash() ) ) {
+ WC()->cart->empty_cart();
+ }
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
index a6e424d1e78..dd7ea7a01c5 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
@@ -9,6 +9,7 @@ use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStockException;
use Automattic\WooCommerce\StoreApi\Utilities\CheckoutTrait;
+use Automattic\WooCommerce\Internal\Checkout\PaymentRecovery;
/**
* Checkout class.
@@ -190,10 +191,9 @@ class Checkout extends AbstractCartRoute {
// that never left checkout-draft included.
// Re-read the order first, since a gateway that advanced it may have done so on its own
// instance, leaving the one held here reporting a stale status.
- $order = $this->order ? wc_get_order( $this->order->get_id() ) : null;
- $order = $order instanceof \WC_Order ? $order : $this->order;
+ $order = $this->order instanceof \WC_Order ? PaymentRecovery::refresh_order( $this->order ) : null;
- if ( $order && ! $this->order_moved_past_payment( $order ) ) {
+ if ( $order && ! PaymentRecovery::order_moved_past_payment( $order ) ) {
wc_release_stock_for_order( $order );
wc_release_coupons_for_order( $order );
}
@@ -572,6 +572,16 @@ class Checkout extends AbstractCartRoute {
$this->cart_controller->validate_cart_not_empty();
wc_log_order_step( '[Store API #2] Cart validated' );
+ /**
+ * Answer a repeat submit before the cart and stock checks: the gateway already moved this
+ * order on and the request died before the cart was emptied, so a new order would charge
+ * the shopper twice, and the first one may have taken the last units.
+ */
+ $session_order = PaymentRecovery::get_session_order_moved_past_payment( (int) $this->get_draft_order_id() );
+ if ( $session_order ) {
+ return $this->get_repeat_submit_response( $session_order, $request );
+ }
+
/**
* Persist customer session data from the request first so that OrderController::update_addresses_from_cart
* uses the up-to-date customer address.
@@ -737,6 +747,38 @@ class Checkout extends AbstractCartRoute {
);
}
+ /**
+ * Answers a submit that repeats one the gateway already took, with that order's received page.
+ *
+ * Both redirect fields are set, as on the normal success path: gateway client code reads the
+ * redirect from the payment details rather than from redirect_url.
+ *
+ * @param \WC_Order $order The order the gateway moved past payment.
+ * @param \WP_REST_Request<array<string, mixed>> $request Request object.
+ * @return \WP_REST_Response
+ */
+ private function get_repeat_submit_response( \WC_Order $order, \WP_REST_Request $request ) { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
+ PaymentRecovery::record_repeat_submit( $order );
+
+ $payment_result = new PaymentResult( 'success' );
+ $payment_result->set_redirect_url( $order->get_checkout_order_received_url() );
+ $payment_result->set_payment_details( array( 'redirect' => $payment_result->get_redirect_url() ) );
+
+ wc_log_order_step(
+ '[Store API #2A] Repeat submit for an order that moved past payment, sending to the order received page',
+ array( 'order_object' => $order ),
+ true
+ );
+
+ return $this->prepare_item_for_response(
+ (object) [
+ 'order' => $order,
+ 'payment_result' => $payment_result,
+ ],
+ $request
+ );
+ }
+
/**
* Get route response when something went wrong.
*
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php b/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php
index 3234cf4b447..42342f2c999 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php
@@ -9,6 +9,7 @@ use Automattic\WooCommerce\StoreApi\Payments\PaymentResult;
use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsSchema\DocumentObject;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\Checkout\PaymentRecovery;
use WC_Customer;
/**
@@ -104,6 +105,7 @@ trait CheckoutTrait {
$session = WC()->session;
$session->set( 'order_awaiting_payment', $order->get_id() );
+ PaymentRecovery::remember_order_sent_to_gateway( $order->get_id() );
// Persist before invoking gateways because redirects or stalled requests may prevent the session from being saved on shutdown.
if ( is_callable( array( $session, 'save_data' ) ) ) {
$session->save_data();
@@ -147,9 +149,9 @@ trait CheckoutTrait {
* Re-read first: a gateway that advanced the order may have done so on its own
* instance, leaving the one held above reporting a stale status.
*/
- $order = $this->refresh_order( $order );
+ $order = PaymentRecovery::refresh_order( $order );
- if ( $this->order_moved_past_payment( $order ) ) {
+ if ( PaymentRecovery::order_moved_past_payment( $order ) ) {
$this->recover_order_that_took_payment( $order, $e, $result_for_recovery );
return;
}
@@ -176,62 +178,6 @@ trait CheckoutTrait {
}
}
- /**
- * Re-reads the order so its status reflects whatever the gateway persisted, falling back
- * to the in-memory order when it can no longer be read.
- *
- * @param \WC_Order $order Order object.
- * @return \WC_Order
- */
- private function refresh_order( \WC_Order $order ): \WC_Order {
- $refreshed_order = wc_get_order( $order->get_id() );
-
- return $refreshed_order instanceof \WC_Order ? $refreshed_order : $order;
- }
-
- /**
- * Whether the order has moved beyond the point of awaiting payment.
- *
- * An order reaches a gateway awaiting payment or as a draft, so any other status means
- * something moved it on. This is about the status, not the money: an order parked on-hold
- * for review counts, because sending the shopper back to place it again would be wrong.
- *
- * The statuses a site declares payable count as awaiting payment, so a custom status an
- * extension parks the order in before the gateway runs is not mistaken for a paid one.
- * Not needs_payment(): that folds in the order total, and a fully discounted order that
- * failed would then keep its stock and coupon holds.
- *
- * @param \WC_Order $order Order object.
- * @return bool
- */
- private function order_moved_past_payment( \WC_Order $order ): bool {
- /**
- * Filter the valid order statuses for payment.
- *
- * The same filter WC_Order::needs_payment() applies. A status a site declares payable
- * counts as awaiting payment here too, so a failure while the order is in it is reported
- * rather than recovered as one that took payment.
- *
- * @since 11.2.0
- *
- * @param array $valid_order_statuses Array of valid order statuses for payment.
- * @param \WC_Order $order Order object.
- */
- $payable_statuses = apply_filters( 'woocommerce_valid_order_statuses_for_payment', array( OrderStatus::PENDING, OrderStatus::FAILED ), $order );
-
- return ! $order->has_status(
- array_merge(
- (array) $payable_statuses,
- array(
- OrderStatus::CHECKOUT_DRAFT,
- OrderStatus::CANCELLED,
- OrderStatus::REFUNDED,
- OrderStatus::TRASH,
- )
- )
- );
- }
-
/**
* Records a failure that happened after payment was taken and reports the checkout as
* successful, so the shopper is sent to the order confirmation rather than back to retry.
@@ -241,30 +187,7 @@ trait CheckoutTrait {
* @param PaymentResult $payment_result Payment result object.
*/
private function recover_order_that_took_payment( \WC_Order $order, \Throwable $error, PaymentResult $payment_result ): void {
- /*
- * The failure goes in the message, not the context: the file handler renders context with
- * wp_json_encode(), and neither WC_Order nor Throwable exposes public properties, so the
- * objects alone would write an empty {}. This is now the only log of the error.
- */
- wc_get_logger()->error(
- sprintf(
- 'Checkout for order #%1$d failed after payment was taken: %2$s: %3$s in %4$s:%5$d',
- $order->get_id(),
- get_class( $error ),
- $error->getMessage(),
- $error->getFile(),
- $error->getLine()
- ),
- array( 'source' => 'store-api' )
- );
-
- $order->add_order_note(
- sprintf(
- /* translators: %s: the error that was raised after payment was taken. */
- __( 'Checkout could not be completed after payment was taken: %s', 'woocommerce' ),
- wp_strip_all_tags( $error->getMessage() )
- )
- );
+ PaymentRecovery::record_failure_after_payment( $order, $error, 'store-api' );
$payment_result->set_status( 'success' );
@@ -290,16 +213,6 @@ trait CheckoutTrait {
$payment_details['redirect'] = $payment_result->get_redirect_url();
$payment_result->set_payment_details( $payment_details );
}
-
- /*
- * The gateway never reached the point where it empties the cart, so do it here: otherwise
- * the shopper lands on the confirmation with the order still in their cart and can place
- * it again. Only when the cart still belongs to this order, since pay-for-order shares
- * this trait with an unrelated cart. Same guard as wc_clear_cart_after_payment().
- */
- if ( WC()->cart && $order->has_cart_hash( WC()->cart->get_cart_hash() ) ) {
- WC()->cart->empty_cart();
- }
}
/**
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
index b21257ee273..3eeb423a6f8 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
@@ -7,6 +7,7 @@
use Automattic\WooCommerce\Checkout\Helpers\ReserveStock;
use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\Checkout\PaymentRecovery;
use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
/**
@@ -2162,6 +2163,26 @@ class WC_Cart_Test extends \WC_Unit_Test_Case {
}
}
+ /**
+ * @testdox Should clear the cart after payment_complete() cleared order_awaiting_payment, when the order sent to the gateway moved past payment.
+ */
+ public function test_clear_cart_after_payment_reads_the_order_sent_to_gateway(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ WC()->cart->add_to_cart( $product->get_id(), 1 );
+
+ $order = WC_Helper_Order::create_order( 1, $product, array( 'status' => OrderStatus::PENDING ) );
+ $order->set_cart_hash( WC()->cart->get_cart_hash() );
+ $order->save();
+
+ PaymentRecovery::remember_order_sent_to_gateway( $order->get_id() );
+ WC()->session->set( 'order_awaiting_payment', $order->get_id() );
+ $order->payment_complete( 'txn_died_in_transition' );
+
+ wc_clear_cart_after_payment();
+
+ $this->assertTrue( WC()->cart->is_empty(), 'The next page load should empty the cart of an order a card gateway already charged, so a retry cannot place it again.' );
+ }
+
/**
* @testdox Should allow woocommerce_should_clear_cart_after_payment to override the final clear cart value.
*/
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php b/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
index 6e8dda0e6a6..3c31cd1921a 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
@@ -6,12 +6,23 @@
*/
use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\Checkout\PaymentRecovery;
+use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\FunctionsMockerHack;
/**
* Class WC_Checkout
*/
class WC_Checkout_Test extends \WC_Unit_Test_Case {
+ use LoggerSpyTrait;
+
+ /**
+ * The least create_order() needs from a posted classic checkout form, paying by cash on delivery.
+ */
+ private const COD_POSTED_DATA = array(
+ 'payment_method' => WC_Gateway_COD::ID,
+ 'billing_email' => 'customer@example.com',
+ );
/**
* @var object The system under test.
@@ -23,6 +34,16 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
*/
private $extra_field_filters = array();
+ /**
+ * @var WC_Session|null The session the test base installed, put back after a test swapped in a real handler.
+ */
+ private $original_session;
+
+ /**
+ * @var WC_Customer|null The customer the test base installed, put back after a test ran a full checkout into a fresh one.
+ */
+ private $original_session_customer;
+
/**
* @var array The customer's location and VAT exemption before each test, restored on tear down.
*/
@@ -87,6 +108,13 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
$this->extra_field_filters = array();
+ if ( $this->original_session ) {
+ WC()->session = $this->original_session;
+ WC()->customer = $this->original_session_customer;
+ $this->original_session = null;
+ $this->original_session_customer = null;
+ }
+
$customer = WC()->customer;
$customer->set_billing_location( $this->original_customer[0], $this->original_customer[1] );
$customer->set_shipping_location( $this->original_customer[2], $this->original_customer[3] );
@@ -1941,6 +1969,525 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
$this->assertStringContainsString( 'Order items could not be saved', $result->get_error_message(), 'Error message should surface the defense-in-depth guard message.' );
}
+ /**
+ * @testdox create_order() decides a repeat submit by the session order's status: "$status" leads to "$expected".
+ *
+ * @testWith ["processing", "refuse"]
+ * ["completed", "refuse"]
+ * ["on-hold", "refuse"]
+ * ["pending", "resume"]
+ * ["failed", "resume"]
+ * ["cancelled", "new-order"]
+ * ["refunded", "new-order"]
+ *
+ * @param string $status Status the session order is in when the form is submitted again.
+ * @param string $expected What create_order() does with it: refuse, resume or new-order.
+ */
+ public function test_create_order_decides_a_repeat_submit_by_the_session_order_status( string $status, string $expected ): void {
+ $first_order_id = $this->leave_session_order_in_status( $status );
+
+ $result = $this->sut->create_order( self::COD_POSTED_DATA );
+
+ switch ( $expected ) {
+ case 'refuse':
+ $this->assertWPError( $result, 'A repeat submit for an order that went through must not build a new order.' );
+ $this->assertSame( 'checkout-order-already-placed', $result->get_error_code() );
+ $this->assertSame( array( 'order_id' => $first_order_id ), $result->get_error_data() );
+ $this->assertSame( array( $first_order_id ), $this->order_ids(), 'No second order should exist.' );
+ break;
+ case 'resume':
+ $this->assertSame( $first_order_id, $result, 'The order awaiting payment should be resumed.' );
+ break;
+ case 'new-order':
+ $this->assertNotWPError( $result );
+ $this->assertNotSame( $first_order_id, $result, 'A new order should be built.' );
+ break;
+ default:
+ $this->fail( "Unknown expectation '{$expected}'." );
+ }
+ }
+
+ /**
+ * @testdox create_order() does not treat a status the site declares payable as a repeat submit.
+ */
+ public function test_create_order_does_not_treat_a_site_declared_payable_status_as_a_repeat_submit(): void {
+ add_filter(
+ 'woocommerce_valid_order_statuses_for_payment',
+ function ( $statuses ) {
+ $statuses[] = OrderStatus::ON_HOLD;
+
+ return $statuses;
+ }
+ );
+ $this->leave_session_order_in_status( OrderStatus::ON_HOLD );
+
+ $result = $this->sut->create_order( self::COD_POSTED_DATA );
+
+ $this->assertNotWPError( $result, 'An on-hold order the site still lets the shopper pay for is not a repeat submit.' );
+ }
+
+ /**
+ * Leave the session the way a gateway that died right after its status change does: the order it points at
+ * has the cart's hash and the given status, and the cart was never emptied.
+ *
+ * @param string $status Status to put the order in.
+ * @return int The order ID.
+ */
+ private function leave_session_order_in_status( string $status ): int {
+ $product = WC_Helper_Product::create_simple_product( true, array( 'virtual' => true ) );
+ WC()->cart->add_to_cart( $product->get_id() );
+ WC()->cart->calculate_totals();
+
+ $order_id = $this->sut->create_order( self::COD_POSTED_DATA );
+ $this->assertNotWPError( $order_id );
+ WC()->session->set( 'order_awaiting_payment', $order_id );
+ wc_get_order( $order_id )->update_status( $status );
+
+ return $order_id;
+ }
+
+ /**
+ * @testdox process_checkout() answers a repeat submit with the order-received redirect when the session order already moved past payment.
+ */
+ public function test_process_checkout_answers_a_repeat_submit_with_the_order_received_redirect(): void {
+ $this->use_real_session();
+ $this->make_gateway_available( WC_Gateway_COD::ID );
+ // What a failure the checkout could not recover leaves behind: the session order moved on, the cart was never emptied.
+ $order = wc_get_order( $this->leave_session_order_in_status( OrderStatus::PROCESSING ) );
+ $this->post_checkout_form( WC_Gateway_COD::ID );
+
+ $response = $this->submit_checkout_over_ajax();
+
+ $this->assertSame( 'success', $response['result'] ?? null, wp_json_encode( $response ) );
+ $this->assertSame( $order->get_checkout_order_received_url(), $response['redirect'] ?? null );
+ $this->assertSame( array( $order->get_id() ), $this->order_ids(), 'The retry must not build a second order.' );
+ $this->assertStringContainsString( 'No second order was created', $this->latest_note( $order ), 'The merchant should see the repeat submit on the order.' );
+ }
+
+ /**
+ * @testdox process_checkout() answers a repeat submit after a card gateway's payment_complete() cleared order_awaiting_payment and the request died.
+ */
+ public function test_process_checkout_answers_a_repeat_submit_after_payment_complete_cleared_the_session(): void {
+ $this->use_real_session();
+ $this->make_gateway_available( WC_Gateway_COD::ID );
+ // What an exit inside a card gateway's status change leaves: the order paid, the older pointer cleared, the cart full.
+ $order = wc_get_order( $this->leave_session_order_in_status( OrderStatus::PENDING ) );
+ PaymentRecovery::remember_order_sent_to_gateway( $order->get_id() );
+ $order->payment_complete( 'txn_died_in_transition' );
+ $this->post_checkout_form( WC_Gateway_COD::ID );
+
+ $response = $this->submit_checkout_over_ajax();
+
+ $this->assertSame( 'success', $response['result'] ?? null, wp_json_encode( $response ) );
+ $this->assertSame( $order->get_checkout_order_received_url(), $response['redirect'] ?? null );
+ $this->assertSame( array( $order->get_id() ), $this->order_ids(), 'The retry must not build and charge a second order.' );
+ }
+
+ /**
+ * @testdox process_checkout() answers a repeat submit before the stock check, when the first order took the last unit.
+ */
+ public function test_process_checkout_answers_a_repeat_submit_before_the_stock_check(): void {
+ $this->use_real_session();
+ $this->make_gateway_available( WC_Gateway_COD::ID );
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'virtual' => true,
+ 'manage_stock' => true,
+ 'stock_quantity' => 1,
+ )
+ );
+ WC()->cart->add_to_cart( $product->get_id() );
+ WC()->cart->calculate_totals();
+ $order = wc_get_order( $this->sut->create_order( self::COD_POSTED_DATA ) );
+ PaymentRecovery::remember_order_sent_to_gateway( $order->get_id() );
+ $order->payment_complete( 'txn_took_the_last_unit' );
+ // The retry is a new request, so its cart holds the product as it is now: out of stock.
+ WC()->cart->get_cart_from_session();
+ $this->post_checkout_form( WC_Gateway_COD::ID );
+
+ $response = $this->submit_checkout_over_ajax();
+
+ $this->assertSame( 0, wc_get_product( $product->get_id() )->get_stock_quantity(), 'The first order took the last unit.' );
+ $this->assertSame( 'success', $response['result'] ?? null, 'The shopper should be sent to the order, not told the product sold out: ' . wp_json_encode( $response ) );
+ $this->assertSame( $order->get_checkout_order_received_url(), $response['redirect'] ?? null );
+ }
+
+ /**
+ * @testdox process_checkout() answers a repeat submit for a free order whose request died after it moved past payment.
+ */
+ public function test_process_checkout_answers_a_repeat_submit_for_a_free_order(): void {
+ $this->use_real_session();
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'virtual' => true,
+ 'regular_price' => 0,
+ 'price' => 0,
+ )
+ );
+ WC()->cart->add_to_cart( $product->get_id() );
+ $this->post_checkout_form( '' );
+ // A free order skips the gateway, but payment_complete() still runs its status change; a plugin dying there is the reported shape.
+ $die_in_transition = function () {
+ throw new Error( 'Call to a member function push_order() on null' );
+ };
+ add_action( 'woocommerce_order_status_processing', $die_in_transition );
+
+ try {
+ $this->submit_checkout_over_ajax();
+ $this->fail( 'A free order is not recovered on the first attempt, so the Error should escape.' );
+ } catch ( Error $e ) {
+ $this->assertSame( 'Call to a member function push_order() on null', $e->getMessage() );
+ }
+ remove_action( 'woocommerce_order_status_processing', $die_in_transition );
+ $order = wc_get_order( $this->order_ids()[0] ?? 0 );
+ $this->post_checkout_form( '' );
+
+ $response = $this->submit_checkout_over_ajax();
+
+ $this->assertInstanceOf( WC_Order::class, $order );
+ $this->assertTrue( $order->has_status( OrderStatus::PROCESSING ), 'The first submit completed the free order before the request died.' );
+ $this->assertSame( 'success', $response['result'] ?? null, wp_json_encode( $response ) );
+ $this->assertSame( $order->get_checkout_order_received_url(), $response['redirect'] ?? null );
+ $this->assertSame( array( $order->get_id() ), $this->order_ids(), 'The retry must not build a second free order.' );
+ }
+
+ /**
+ * @testdox process_checkout() recovers an order the gateway moved on when an Error is raised inside the status transition.
+ */
+ public function test_process_checkout_recovers_an_order_the_gateway_moved_on_when_the_transition_throws(): void {
+ $this->use_real_session();
+ $this->make_gateway_available( WC_Gateway_BACS::ID );
+ WC()->cart->add_to_cart( WC_Helper_Product::create_simple_product( true, array( 'virtual' => true ) )->get_id() );
+ $this->post_checkout_form( WC_Gateway_BACS::ID );
+ // BACS moves the order on-hold and only then empties the cart; a plugin dying in between is the reported shape.
+ add_action(
+ 'woocommerce_order_status_on-hold',
+ function () {
+ throw new Error( 'Call to a member function push_order() on null' );
+ }
+ );
+
+ $response = $this->submit_checkout_over_ajax();
+
+ $order = wc_get_order( $this->order_ids()[0] ?? 0 );
+ $this->assertInstanceOf( WC_Order::class, $order );
+ $this->assertSame( 'success', $response['result'] ?? null, wp_json_encode( $response ) );
+ $this->assertSame( $order->get_checkout_order_received_url(), $response['redirect'] ?? null );
+ $this->assertTrue( $order->has_status( OrderStatus::ON_HOLD ), 'The status the gateway set must stand.' );
+ $this->assertTrue( WC()->cart->is_empty(), 'The cart the gateway never got to empty should be emptied.' );
+ $this->assertStringContainsString( 'push_order() on null', $this->latest_note( $order ), 'The merchant should see the failure on the order.' );
+ $this->assertLogged( 'error', 'push_order() on null' );
+ $this->assertLogged( 'error', __FILE__ );
+ }
+
+ /**
+ * @testdox process_checkout() recovers when a gateway that calls payment_complete() dies inside the resulting status transition.
+ */
+ public function test_process_checkout_recovers_when_a_payment_complete_gateway_dies_in_the_transition(): void {
+ $this->use_real_session();
+ $this->make_gateway_available(
+ $this->create_gateway(
+ 'card_like',
+ function ( WC_Order $order ) {
+ $order->payment_complete( 'txn_1' );
+ }
+ )
+ );
+ WC()->cart->add_to_cart( WC_Helper_Product::create_simple_product( true, array( 'virtual' => true ) )->get_id() );
+ $this->post_checkout_form( 'card_like' );
+ add_action(
+ 'woocommerce_order_status_processing',
+ function () {
+ throw new Error( 'Call to a member function push_order() on null' );
+ }
+ );
+
+ $response = $this->submit_checkout_over_ajax();
+
+ $order = wc_get_order( $this->order_ids()[0] ?? 0 );
+ $this->assertInstanceOf( WC_Order::class, $order );
+ $this->assertSame( 'success', $response['result'] ?? null, wp_json_encode( $response ) );
+ $this->assertSame( $order->get_checkout_order_received_url(), $response['redirect'] ?? null );
+ $this->assertTrue( $order->has_status( OrderStatus::PROCESSING ) );
+ $this->assertTrue( WC()->cart->is_empty() );
+ }
+
+ /**
+ * @testdox process_checkout() recovers when an Exception follows a successful payment result.
+ */
+ public function test_process_checkout_recovers_when_an_exception_follows_payment(): void {
+ $this->use_real_session();
+ $this->make_gateway_available( WC_Gateway_BACS::ID );
+ WC()->cart->add_to_cart( WC_Helper_Product::create_simple_product( true, array( 'virtual' => true ) )->get_id() );
+ $this->post_checkout_form( WC_Gateway_BACS::ID );
+ add_filter(
+ 'woocommerce_payment_successful_result',
+ function () {
+ throw new Exception( 'Analytics integration timed out' );
+ }
+ );
+
+ $response = $this->submit_checkout_over_ajax();
+
+ $order = wc_get_order( $this->order_ids()[0] ?? 0 );
+ $this->assertInstanceOf( WC_Order::class, $order );
+ $this->assertSame( 'success', $response['result'] ?? null, wp_json_encode( $response ) );
+ $this->assertTrue( $order->has_status( OrderStatus::ON_HOLD ) );
+ $this->assertStringContainsString( 'Analytics integration timed out', $this->latest_note( $order ) );
+ }
+
+ /**
+ * @testdox process_checkout() lets an Error raised before the gateway moved the order on escape unchanged.
+ */
+ public function test_process_checkout_lets_an_error_raised_before_payment_escape(): void {
+ $this->use_real_session();
+ $this->make_gateway_available(
+ $this->create_gateway(
+ 'exploding',
+ function () {
+ throw new Error( 'Gateway SDK not loaded' );
+ }
+ )
+ );
+ WC()->cart->add_to_cart( WC_Helper_Product::create_simple_product( true, array( 'virtual' => true ) )->get_id() );
+ $this->post_checkout_form( 'exploding' );
+
+ try {
+ $this->submit_checkout_over_ajax();
+ $this->fail( 'An Error raised before payment should still reach the fatal handler.' );
+ } catch ( Error $e ) {
+ $this->assertSame( 'Gateway SDK not loaded', $e->getMessage() );
+ }
+
+ $order = wc_get_order( $this->order_ids()[0] ?? 0 );
+ $this->assertInstanceOf( WC_Order::class, $order );
+ $this->assertTrue( $order->has_status( OrderStatus::PENDING ), 'Nothing moved the order on, so nothing should be recovered.' );
+ $this->assertFalse( WC()->cart->is_empty() );
+ $this->assertStringNotContainsString( 'after payment', $this->latest_note( $order ) );
+ }
+
+ /**
+ * @testdox process_checkout() reports an Exception raised before the gateway moved the order on as a failure, as before.
+ */
+ public function test_process_checkout_reports_an_exception_raised_before_payment_as_a_failure(): void {
+ $this->use_real_session();
+ $this->make_gateway_available(
+ $this->create_gateway(
+ 'declining',
+ function () {
+ throw new Exception( 'Your card was declined.' );
+ }
+ )
+ );
+ WC()->cart->add_to_cart( WC_Helper_Product::create_simple_product( true, array( 'virtual' => true ) )->get_id() );
+ $this->post_checkout_form( 'declining' );
+
+ $response = $this->submit_checkout_over_ajax();
+
+ $order = wc_get_order( $this->order_ids()[0] ?? 0 );
+ $this->assertInstanceOf( WC_Order::class, $order );
+ $this->assertSame( 'failure', $response['result'] ?? null, wp_json_encode( $response ) );
+ $this->assertStringContainsString( 'Your card was declined.', $response['messages'] ?? '' );
+ $this->assertTrue( $order->has_status( OrderStatus::PENDING ) );
+ $this->assertFalse( WC()->cart->is_empty(), 'The shopper should be able to try again with the same cart.' );
+ }
+
+ /**
+ * @testdox process_checkout() remembers the order it hands to the gateway before the gateway runs.
+ */
+ public function test_process_checkout_remembers_the_order_sent_to_gateway(): void {
+ $this->use_real_session();
+
+ $remembered_during_payment = null;
+ $this->make_gateway_available(
+ $this->create_gateway(
+ 'recording',
+ function () use ( &$remembered_during_payment ) {
+ $remembered_during_payment = WC()->session->get( 'order_sent_to_gateway' );
+ throw new Exception( 'Stop after recording.' );
+ }
+ )
+ );
+ WC()->cart->add_to_cart( WC_Helper_Product::create_simple_product( true, array( 'virtual' => true ) )->get_id() );
+ $this->post_checkout_form( 'recording' );
+
+ $this->submit_checkout_over_ajax();
+
+ $this->assertSame( $this->order_ids()[0] ?? null, $remembered_during_payment, 'The gateway should run with its order already remembered, so a request that dies inside it can be traced back.' );
+ }
+
+ /**
+ * IDs of every order in the database, oldest first.
+ *
+ * @return int[]
+ */
+ private function order_ids(): array {
+ return wc_get_orders(
+ array(
+ 'return' => 'ids',
+ 'limit' => -1,
+ 'orderby' => 'ID',
+ 'order' => 'ASC',
+ )
+ );
+ }
+
+ /**
+ * Content of the most recent note on an order, or an empty string.
+ *
+ * @param WC_Order $order Order object.
+ * @return string
+ */
+ private function latest_note( WC_Order $order ): string {
+ $notes = wc_get_order_notes(
+ array(
+ 'order_id' => $order->get_id(),
+ 'limit' => 1,
+ )
+ );
+
+ return $notes[0]->content ?? '';
+ }
+
+ /**
+ * Build a throwaway gateway whose process_payment() runs the given callback with the order, then reports success.
+ *
+ * @param string $id Gateway ID.
+ * @param callable $process_payment Called with the WC_Order; throw from it to simulate a gateway failure.
+ * @return WC_Payment_Gateway
+ */
+ private function create_gateway( string $id, callable $process_payment ): WC_Payment_Gateway {
+ // phpcs:disable Generic.CodeAnalysis, Squiz.Commenting
+ return new class( $id, $process_payment ) extends WC_Payment_Gateway {
+ private $process_payment_callback;
+
+ public function __construct( string $id, callable $process_payment ) {
+ $this->id = $id;
+ $this->title = $id;
+ $this->enabled = 'yes';
+ $this->process_payment_callback = $process_payment;
+ }
+
+ public function process_payment( $order_id ) {
+ $order = wc_get_order( $order_id );
+ ( $this->process_payment_callback )( $order );
+
+ return array(
+ 'result' => 'success',
+ 'redirect' => $this->get_return_url( $order ),
+ );
+ }
+ };
+ // phpcs:enable Generic.CodeAnalysis, Squiz.Commenting
+ }
+
+ /**
+ * Swap the test base's in-memory session for a real handler, which is what process_order_payment() needs (it calls save_data()).
+ *
+ * The customer is swapped too, because a full checkout writes the posted address into it and nothing else resets that singleton.
+ */
+ private function use_real_session(): void {
+ $this->original_session = WC()->session;
+ $this->original_session_customer = WC()->customer;
+
+ WC()->session = new WC_Session_Handler();
+ WC()->session->init();
+ WC()->session->set_customer_session_cookie( true );
+ WC()->customer = new WC_Customer( 0, true );
+ }
+
+ /**
+ * Make a gateway available at checkout without touching stored settings: a registered one by ID, or an instance.
+ *
+ * @param string|WC_Payment_Gateway $gateway Gateway ID or gateway object.
+ */
+ private function make_gateway_available( $gateway ): void {
+ $gateway = $gateway instanceof WC_Payment_Gateway ? $gateway : WC()->payment_gateways()->payment_gateways()[ $gateway ];
+
+ add_filter(
+ 'woocommerce_available_payment_gateways',
+ function ( $gateways ) use ( $gateway ) {
+ $gateways[ $gateway->id ] = $gateway;
+
+ return $gateways;
+ }
+ );
+ }
+
+ /**
+ * Fill the request the way the classic checkout form posts it, for a guest buying a virtual product.
+ *
+ * Call it after use_real_session(): the nonce takes its logged-out user ID from the session, so one created
+ * before the swap fails verification after it.
+ *
+ * @param string $payment_method Gateway ID.
+ */
+ private function post_checkout_form( string $payment_method ): void {
+ update_option( 'woocommerce_enable_guest_checkout', 'yes' );
+
+ $_POST = array(
+ 'woocommerce-process-checkout-nonce' => wp_create_nonce( 'woocommerce-process_checkout' ),
+ 'payment_method' => $payment_method,
+ 'billing_first_name' => 'Ada',
+ 'billing_last_name' => 'Lovelace',
+ 'billing_address_1' => '1 Analytical Engine Way',
+ 'billing_city' => 'Los Angeles',
+ 'billing_state' => 'CA',
+ 'billing_postcode' => '90210',
+ 'billing_country' => 'US',
+ 'billing_email' => 'customer@example.com',
+ 'billing_phone' => '555-555-5555',
+ );
+ $_REQUEST = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Test fixture; the request carries the nonce created just above.
+ }
+
+ /**
+ * Run process_checkout() as the checkout JS does, returning the JSON it answers with.
+ *
+ * Anything other than the wp_send_json() exit, such as an Error a hook raises, propagates to the caller.
+ * The exit is an Error rather than an Exception because process_checkout() catches Exception itself.
+ *
+ * Cannot drive a gateway that returns success: its exit happens inside process_order_payment(), so
+ * process_checkout() catches it and recovers it as a post-payment failure, and two JSON bodies come back.
+ * Make the gateway throw before that point, or take the repeat-submit path, which exits outside the catch.
+ *
+ * @return array Decoded JSON response.
+ */
+ private function submit_checkout_over_ajax(): array {
+ $throw_instead_of_dying = function () {
+ return function () {
+ throw new Error( 'wp_die' );
+ };
+ };
+ add_filter( 'wp_doing_ajax', '__return_true' );
+ add_filter( 'wp_die_ajax_handler', $throw_instead_of_dying );
+ $outer_buffer_level = ob_get_level();
+ ob_start();
+
+ try {
+ $this->sut->process_checkout();
+ $this->fail( 'process_checkout() should end the request with wp_send_json().' );
+ } catch ( Error $e ) {
+ if ( 'wp_die' !== $e->getMessage() ) {
+ throw $e;
+ }
+ $output = (string) ob_get_clean();
+ } finally {
+ while ( ob_get_level() > $outer_buffer_level ) {
+ ob_end_clean();
+ }
+ remove_filter( 'wp_doing_ajax', '__return_true' );
+ remove_filter( 'wp_die_ajax_handler', $throw_instead_of_dying );
+ }
+
+ $decoded = json_decode( $output, true );
+ $this->assertIsArray( $decoded, $output );
+
+ return $decoded;
+ }
+
/**
* @testdox Checkout tolerates non-array cart variation data when building contextual item names.
*/
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
index 99f02f0ff42..559d12fd4a5 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
@@ -3874,16 +3874,25 @@ class Checkout extends \WP_Test_REST_TestCase {
}
/**
- * @testdox Checkout does not reuse a zero-total order from the session once it has moved past awaiting payment.
+ * @testdox Checkout answers with the zero-total order from the session, without placing another, once that order has moved past awaiting payment.
*/
- public function test_post_does_not_reuse_completed_zero_total_order_from_session(): void {
+ public function test_post_answers_with_completed_zero_total_order_from_session(): void {
$this->apply_full_discount_coupon();
$order = $this->create_session_order_from_cart( OrderStatus::COMPLETED );
$response = rest_get_server()->dispatch( $this->build_valid_post_request() );
+ $order_ids = wc_get_orders(
+ array(
+ 'limit' => -1,
+ 'status' => 'any',
+ 'return' => 'ids',
+ )
+ );
+
$this->assertEquals( 200, $response->get_status(), print_r( $response->get_data(), true ) );
- $this->assertNotSame( $order->get_id(), (int) $response->get_data()['order_id'], 'A completed order must not be placed again.' );
+ $this->assertSame( array( $order->get_id() ), $order_ids, 'A completed order must not be placed again.' );
+ $this->assertSame( $order->get_checkout_order_received_url(), $response->get_data()['payment_result']['redirect_url'], 'The shopper should be sent to the order received page of the completed order.' );
$this->assertTrue( wc_get_order( $order->get_id() )->has_status( OrderStatus::COMPLETED ), 'The completed order should be left as it was.' );
}
@@ -3969,6 +3978,29 @@ class Checkout extends \WP_Test_REST_TestCase {
$this->assertSame( $order_id, (int) $persisted_session_data['order_awaiting_payment'], 'Redirect payments should leave the persisted order linked to the shopper session.' );
}
+ /**
+ * @testdox Store API checkout remembers the order it hands to the gateway before the gateway runs.
+ */
+ public function test_post_remembers_the_order_sent_to_gateway(): void {
+ $remembered_during_payment = null;
+ $payment_handler = function ( $context, $payment_result ) use ( &$remembered_during_payment ) {
+ unset( $context );
+ $remembered_during_payment = WC()->session->get( 'order_sent_to_gateway' );
+ $payment_result->set_status( 'success' );
+ };
+
+ add_action( 'woocommerce_rest_checkout_process_payment_with_context', $payment_handler, 1, 2 );
+
+ try {
+ $response = rest_get_server()->dispatch( $this->build_valid_post_request() );
+ } finally {
+ remove_action( 'woocommerce_rest_checkout_process_payment_with_context', $payment_handler, 1 );
+ }
+
+ $this->assertEquals( 200, $response->get_status(), print_r( $response->get_data(), true ) );
+ $this->assertSame( (int) $response->get_data()['order_id'], $remembered_during_payment, 'The gateway should run with its order already remembered, so a request that dies inside it can be traced back.' );
+ }
+
/**
* @testdox Each checkout request handled in the same PHP request receives its own payment data, as Store API batch subrequests do.
*/
@@ -4339,6 +4371,106 @@ class Checkout extends \WP_Test_REST_TestCase {
);
}
+ /**
+ * @testdox Retrying after the request died between payment_complete() and the cart being emptied does not place a second paid order.
+ */
+ public function test_retry_after_the_request_died_post_payment_does_not_duplicate_the_order() {
+ // PHPUnit cannot survive a real exit, so arrange the state one leaves behind: the first
+ // POST stops before payment, as an exit inside the transition would stop before the
+ // catch, and the gateway's payment_complete() is then run on the order directly.
+ $stop_before_payment = function () {
+ throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException(
+ 'woocommerce_rest_checkout_payment_failed',
+ 'Stands in for the request dying after payment_complete()',
+ 400
+ );
+ };
+ add_action( 'woocommerce_store_api_checkout_order_processed', $stop_before_payment, 999 );
+
+ try {
+ rest_get_server()->dispatch( $this->build_checkout_post_request() );
+ } finally {
+ remove_action( 'woocommerce_store_api_checkout_order_processed', $stop_before_payment, 999 );
+ }
+
+ $paid_order_id = (int) WC()->session->get( 'store_api_draft_order' );
+ $this->assertGreaterThan( 0, $paid_order_id, 'The session should point at the order the first attempt created.' );
+
+ $paid_order = wc_get_order( $paid_order_id );
+ $paid_order->payment_complete( 'txn_died_after_payment' );
+
+ $this->assertFalse( wc_get_order( $paid_order_id )->needs_payment(), 'The gateway took payment before the request died.' );
+ $this->assertFalse( WC()->cart->is_empty(), 'The request died before anything emptied the cart.' );
+ $this->assertSame( $paid_order_id, (int) WC()->session->get( 'store_api_draft_order' ), 'payment_complete() leaves the Store API pointer in place.' );
+
+ $this->simulate_fresh_request();
+ $retry = rest_get_server()->dispatch( $this->build_checkout_post_request() );
+
+ $order_ids_after_retry = wc_get_orders(
+ array(
+ 'limit' => -1,
+ 'status' => 'any',
+ 'return' => 'ids',
+ )
+ );
+
+ $this->assertSame(
+ array( $paid_order_id ),
+ $order_ids_after_retry,
+ 'Retrying after the request died post-payment must not create a second paid order. Retry answered: ' . print_r( $retry->get_data(), true )
+ );
+ $this->assertSame( $paid_order_id, $retry->get_data()['order_id'], 'The retry should answer with the order that was already paid.' );
+ $this->assertSame( 'success', $retry->get_data()['payment_result']['payment_status'] );
+ $this->assertSame( wc_get_order( $paid_order_id )->get_checkout_order_received_url(), $retry->get_data()['payment_result']['redirect_url'], 'The shopper should be sent to the order received page of the paid order.' );
+ }
+
+ /**
+ * @testdox Retrying after the request died post-payment is answered with the paid order, not a stock error, when that order took the last unit.
+ */
+ public function test_retry_after_the_paid_order_took_the_last_unit_is_answered_with_that_order() {
+ // Its own product rather than a class fixture, so enabling stock management here cannot
+ // leak into the other tests in this class.
+ $product = \WC_Helper_Product::create_simple_product();
+ $product->set_manage_stock( true );
+ $product->set_stock_quantity( 1 );
+ $product->set_backorders( 'no' );
+ $product->save();
+ update_option( 'woocommerce_manage_stock', 'yes' );
+
+ WC()->cart->empty_cart();
+ WC()->cart->add_to_cart( $product->get_id(), 1 );
+
+ // Same arrangement as the test above: the first POST stops before payment, then the gateway's
+ // payment_complete() runs on the order directly, which takes the last unit.
+ $stop_before_payment = function () {
+ throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException(
+ 'woocommerce_rest_checkout_payment_failed',
+ 'Stands in for the request dying after payment_complete()',
+ 400
+ );
+ };
+ add_action( 'woocommerce_store_api_checkout_order_processed', $stop_before_payment, 999 );
+
+ try {
+ rest_get_server()->dispatch( $this->build_checkout_post_request() );
+ } finally {
+ remove_action( 'woocommerce_store_api_checkout_order_processed', $stop_before_payment, 999 );
+ }
+
+ $paid_order_id = (int) WC()->session->get( 'store_api_draft_order' );
+ wc_get_order( $paid_order_id )->payment_complete( 'txn_took_the_last_unit' );
+ $this->assertSame( 0, wc_get_product( $product->get_id() )->get_stock_quantity(), 'The paid order took the last unit.' );
+
+ // The retry is a new request, so its cart holds the product as it is now: out of stock.
+ $this->simulate_fresh_request();
+ WC()->cart->get_cart_from_session();
+ $retry = rest_get_server()->dispatch( $this->build_checkout_post_request() );
+
+ $this->assertEquals( 200, $retry->get_status(), 'The shopper should be sent to the order, not told the product sold out: ' . print_r( $retry->get_data(), true ) );
+ $this->assertSame( $paid_order_id, $retry->get_data()['order_id'], 'The retry should answer with the order that took the last unit.' );
+ $this->assertSame( 'success', $retry->get_data()['payment_result']['payment_status'] );
+ }
+
/**
* @testdox A recovered checkout empties the cart the gateway never got to.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/Checkout/PaymentRecoveryTest.php b/plugins/woocommerce/tests/php/src/Internal/Checkout/PaymentRecoveryTest.php
new file mode 100644
index 00000000000..57dda1f5f1a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Checkout/PaymentRecoveryTest.php
@@ -0,0 +1,263 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Checkout;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\Checkout\PaymentRecovery;
+use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
+use WC_Helper_Product;
+use WC_Order;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the PaymentRecovery class.
+ */
+class PaymentRecoveryTest extends WC_Unit_Test_Case {
+
+ use LoggerSpyTrait;
+
+ /**
+ * @testdox Should treat every status a gateway cannot leave an order awaiting payment in as past payment.
+ *
+ * @testWith ["processing", true]
+ * ["completed", true]
+ * ["on-hold", true]
+ * ["pending", false]
+ * ["failed", false]
+ * ["checkout-draft", false]
+ * ["cancelled", false]
+ * ["refunded", false]
+ *
+ * @param string $status Status to leave the order in.
+ * @param bool $expected Whether the order counts as moved past payment.
+ */
+ public function test_order_moved_past_payment_decides_by_status( string $status, bool $expected ): void {
+ $order = new WC_Order();
+ $order->set_status( $status );
+ $order->save();
+
+ $this->assertSame(
+ $expected,
+ PaymentRecovery::order_moved_past_payment( $order ),
+ sprintf( 'An order in %s should%s count as moved past payment.', $status, $expected ? '' : ' not' )
+ );
+ }
+
+ /**
+ * @testdox Should leave an order awaiting payment when the site declares its status payable.
+ */
+ public function test_order_moved_past_payment_respects_the_payable_statuses_filter(): void {
+ $order = new WC_Order();
+ $order->set_status( OrderStatus::ON_HOLD );
+ $order->save();
+
+ $declare_on_hold_payable = function ( $statuses ) {
+ $statuses[] = OrderStatus::ON_HOLD;
+ return $statuses;
+ };
+
+ add_filter( 'woocommerce_valid_order_statuses_for_payment', $declare_on_hold_payable );
+
+ try {
+ $this->assertFalse(
+ PaymentRecovery::order_moved_past_payment( $order ),
+ 'A status the site declares payable is still awaiting payment.'
+ );
+ } finally {
+ remove_filter( 'woocommerce_valid_order_statuses_for_payment', $declare_on_hold_payable );
+ }
+ }
+
+ /**
+ * @testdox Should read the order again so a status the gateway set on its own instance is seen.
+ */
+ public function test_refresh_order_returns_the_stored_order(): void {
+ $order = new WC_Order();
+ $order->set_status( OrderStatus::PENDING );
+ $order->save();
+
+ $stale = wc_get_order( $order->get_id() );
+ $order->set_status( OrderStatus::PROCESSING );
+ $order->save();
+
+ $refreshed = PaymentRecovery::refresh_order( $stale );
+
+ $this->assertSame( OrderStatus::PROCESSING, $refreshed->get_status(), 'The refreshed order carries the stored status.' );
+ }
+
+ /**
+ * @testdox Should hand back the order it was given when the order can no longer be read.
+ */
+ public function test_refresh_order_falls_back_to_the_given_order(): void {
+ $order = new WC_Order();
+ $order->set_status( OrderStatus::PROCESSING );
+ $order->save();
+
+ $order->delete( true );
+
+ $this->assertSame( $order, PaymentRecovery::refresh_order( $order ), 'A deleted order is handed back unchanged.' );
+ }
+
+ /**
+ * @testdox Should remember the order sent to the gateway under a key payment_complete() leaves alone.
+ */
+ public function test_the_order_sent_to_gateway_outlives_payment_complete(): void {
+ $order = new WC_Order();
+ $order->set_status( OrderStatus::PENDING );
+ $order->save();
+ WC()->session->set( 'order_awaiting_payment', $order->get_id() );
+
+ PaymentRecovery::remember_order_sent_to_gateway( $order->get_id() );
+ $order->payment_complete( 'txn_1' );
+
+ $this->assertFalse( WC()->session->get( 'order_awaiting_payment' ), 'payment_complete() clears the older pointer, which is the gap this key closes.' );
+ $this->assertSame( $order->get_id(), WC()->session->get( 'order_sent_to_gateway' ), 'The order sent to the gateway is still known after payment_complete().' );
+ }
+
+ /**
+ * @testdox Should forget the order sent to the gateway once the cart is emptied.
+ */
+ public function test_emptying_the_cart_forgets_the_order_sent_to_gateway(): void {
+ $order = new WC_Order();
+ $order->save();
+ PaymentRecovery::remember_order_sent_to_gateway( $order->get_id() );
+
+ WC()->cart->empty_cart();
+
+ $this->assertEmpty( WC()->session->get( 'order_sent_to_gateway' ), 'An emptied cart has nothing left to place twice.' );
+ }
+
+ /**
+ * @testdox Should find the order sent to the gateway only once it moved past payment.
+ *
+ * @testWith ["processing", true]
+ * ["on-hold", true]
+ * ["pending", false]
+ * ["failed", false]
+ *
+ * @param string $status Status the order is left in.
+ * @param bool $is_found Whether the order should be found as moved past payment.
+ */
+ public function test_session_order_moved_past_payment_decides_by_status( string $status, bool $is_found ): void {
+ $order = $this->order_for_the_current_cart( $status );
+ PaymentRecovery::remember_order_sent_to_gateway( $order->get_id() );
+
+ $found = PaymentRecovery::get_session_order_moved_past_payment();
+
+ $this->assertSame( $is_found ? $order->get_id() : null, $found ? $found->get_id() : null, sprintf( 'An order in %s should%s be found.', $status, $is_found ? '' : ' not' ) );
+ }
+
+ /**
+ * @testdox Should not find the order sent to the gateway once the cart holds something else.
+ */
+ public function test_session_order_moved_past_payment_ignores_a_different_cart(): void {
+ $order = $this->order_for_the_current_cart( OrderStatus::PROCESSING );
+ PaymentRecovery::remember_order_sent_to_gateway( $order->get_id() );
+
+ WC()->cart->add_to_cart( WC_Helper_Product::create_simple_product()->get_id() );
+
+ $this->assertNull( PaymentRecovery::get_session_order_moved_past_payment(), 'A changed cart is a new purchase, not a repeat of the old one.' );
+ }
+
+ /**
+ * @testdox Should fall back to the order the caller names when nothing was remembered.
+ */
+ public function test_session_order_moved_past_payment_falls_back_to_the_given_order(): void {
+ $order = $this->order_for_the_current_cart( OrderStatus::PROCESSING );
+
+ $found = PaymentRecovery::get_session_order_moved_past_payment( $order->get_id() );
+
+ $this->assertSame( $order->get_id(), $found ? $found->get_id() : null, 'A session written before the key existed still points at the order.' );
+ }
+
+ /**
+ * @testdox Should note on the order that a repeat submit was answered without a second order.
+ */
+ public function test_record_repeat_submit_notes_the_order(): void {
+ $order = new WC_Order();
+ $order->set_status( OrderStatus::PROCESSING );
+ $order->save();
+
+ PaymentRecovery::record_repeat_submit( $order );
+
+ $notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+ $this->assertStringContainsString( 'No second order was created', $notes[0]->content, 'The merchant should see that the shopper submitted again and was not charged twice.' );
+ }
+
+ /**
+ * @testdox Should log the failure under the caller's source and note it on the order.
+ */
+ public function test_record_failure_after_payment_logs_and_notes_the_failure(): void {
+ $order = new WC_Order();
+ $order->set_status( OrderStatus::PROCESSING );
+ $order->save();
+
+ $error = new \Error( 'Call to a member function push_order() on null' );
+
+ PaymentRecovery::record_failure_after_payment( $order, $error, 'checkout' );
+
+ $this->assertLogged( 'error', sprintf( 'Checkout for order #%d failed after it moved past the payment step', $order->get_id() ) );
+ $this->assertLogged( 'error', 'push_order() on null' );
+ $this->assertLogged( 'error', __FILE__ );
+
+ $notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+
+ $this->assertStringContainsString(
+ 'Checkout could not be completed after the order moved past the payment step. The order keeps the status the gateway set. Error: Call to a member function push_order() on null',
+ $notes[0]->content,
+ 'The merchant is told what failed and why the order kept its status.'
+ );
+ }
+
+ /**
+ * @testdox Should empty the cart the gateway left behind when it still holds this order.
+ */
+ public function test_record_failure_after_payment_empties_a_cart_that_matches_the_order(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ WC()->cart->add_to_cart( $product->get_id() );
+
+ $order = new WC_Order();
+ $order->set_status( OrderStatus::PROCESSING );
+ $order->set_cart_hash( WC()->cart->get_cart_hash() );
+ $order->save();
+
+ PaymentRecovery::record_failure_after_payment( $order, new \Error( 'boom' ), 'checkout' );
+
+ $this->assertTrue( WC()->cart->is_empty(), 'The cart that produced the order is emptied.' );
+ }
+
+ /**
+ * @testdox Should leave a cart holding something else alone, as a second tab would.
+ */
+ public function test_record_failure_after_payment_leaves_an_unrelated_cart_alone(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ WC()->cart->add_to_cart( $product->get_id() );
+
+ $order = new WC_Order();
+ $order->set_status( OrderStatus::PROCESSING );
+ $order->set_cart_hash( 'a-different-cart' );
+ $order->save();
+
+ PaymentRecovery::record_failure_after_payment( $order, new \Error( 'boom' ), 'checkout' );
+
+ $this->assertFalse( WC()->cart->is_empty(), 'A cart holding another order is left as it is.' );
+ }
+
+ /**
+ * Fills the cart with one product and saves an order in the given status that the cart produced.
+ *
+ * @param string $status Status to save the order in.
+ * @return WC_Order
+ */
+ private function order_for_the_current_cart( string $status ): WC_Order {
+ WC()->cart->add_to_cart( WC_Helper_Product::create_simple_product()->get_id() );
+
+ $order = new WC_Order();
+ $order->set_status( $status );
+ $order->set_cart_hash( WC()->cart->get_cart_hash() );
+ $order->save();
+
+ return $order;
+ }
+}