Commit b75ed30ea0e for woocommerce

commit b75ed30ea0e3e21b4fdb64754348af8743adff5d
Author: Mayisha <33387139+Mayisha@users.noreply.github.com>
Date:   Thu Mar 5 00:28:36 2026 +0600

    PayPal Standard: Handle duplicate invoice id error (#63203)

    * add constant for duplicate invoice id issue

    * update invoice id in paypal order on duplicate invoice id error

    * retry capture

    * update error message

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

    * remove hardcoded test data

    * add retry flag

    * append a random number with site id

    * address feedback

    * use patch method in request

    * fix lint

    * add tests

    * update comment

    * check full url

    * remove response body from exception

    * log patch invoice id failure

    * assert invoice id change

    * fix lint

diff --git a/plugins/woocommerce/changelog/63203-fix-handle-paypal-satndard-duplicate-invoice-id-error b/plugins/woocommerce/changelog/63203-fix-handle-paypal-satndard-duplicate-invoice-id-error
new file mode 100644
index 00000000000..8528e44b1d9
--- /dev/null
+++ b/plugins/woocommerce/changelog/63203-fix-handle-paypal-satndard-duplicate-invoice-id-error
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix - Handle PayPal Standard duplicate invoice ID errors by updating the invoice ID in the PayPal order and retrying payment capture.
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Gateways/PayPal/Constants.php b/plugins/woocommerce/src/Gateways/PayPal/Constants.php
index ef27a2d4425..f85c2fe7826 100644
--- a/plugins/woocommerce/src/Gateways/PayPal/Constants.php
+++ b/plugins/woocommerce/src/Gateways/PayPal/Constants.php
@@ -144,6 +144,13 @@ class Constants {
 	 */
 	const PAYPAL_ISSUE_PAYEE_ACCOUNT_RESTRICTED = 'PAYEE_ACCOUNT_RESTRICTED';

+	/**
+	 * PayPal duplicate invoice ID issue code.
+	 *
+	 * @var string
+	 */
+	const PAYPAL_ISSUE_DUPLICATE_INVOICE_ID = 'DUPLICATE_INVOICE_ID';
+
 	/**
 	 * Meta key for storing PayPal payment status in order meta.
 	 *
diff --git a/plugins/woocommerce/src/Gateways/PayPal/Request.php b/plugins/woocommerce/src/Gateways/PayPal/Request.php
index f341aea74fb..8613c735d39 100644
--- a/plugins/woocommerce/src/Gateways/PayPal/Request.php
+++ b/plugins/woocommerce/src/Gateways/PayPal/Request.php
@@ -213,10 +213,11 @@ class Request {
 	 * @param WC_Order|null $order Order object.
 	 * @param string|null   $action_url The URL to authorize or capture the payment.
 	 * @param string        $action The action to perform. Either 'authorize' or 'capture'.
+	 * @param bool          $is_retry Whether the payment is being retried.
 	 * @return void
 	 * @throws Exception If the PayPal payment authorization or capture fails.
 	 */
-	public function authorize_or_capture_payment( ?WC_Order $order, ?string $action_url, string $action = PayPalConstants::PAYMENT_ACTION_CAPTURE ): void {
+	public function authorize_or_capture_payment( ?WC_Order $order, ?string $action_url, string $action = PayPalConstants::PAYMENT_ACTION_CAPTURE, bool $is_retry = false ): void {
 		if ( ! $order ) {
 			\WC_Gateway_Paypal::log( 'Order not found to authorize or capture payment.' );
 			return;
@@ -267,6 +268,16 @@ class Request {
 			$body          = wp_remote_retrieve_body( $response );
 			$response_data = json_decode( $body, true );

+			$issue                = isset( $response_data['details'][0]['issue'] ) ? $response_data['details'][0]['issue'] : '';
+			$duplicate_invoice_id = 422 === $http_code && PayPalConstants::PAYPAL_ISSUE_DUPLICATE_INVOICE_ID === $issue;
+
+			// If the payment failed with a duplicate invoice ID error and it's not a retry, handle it.
+			// If it's a retry, don't handle it again.
+			if ( $duplicate_invoice_id && ! $is_retry ) {
+				$this->handle_duplicate_invoice_id( $order, $paypal_order_id, $action_url, $action );
+				return;
+			}
+
 			if ( 200 !== $http_code && 201 !== $http_code ) {
 				$paypal_debug_id = isset( $response_data['debug_id'] ) ? $response_data['debug_id'] : null;
 				throw new Exception( 'PayPal ' . $action . ' payment failed. Response status: ' . $http_code . '. Response body: ' . $body );
@@ -416,6 +427,89 @@ class Request {
 		}
 	}

+	/**
+	 * Handle duplicate invoice ID.
+	 * This is a workaround to handle the duplicate invoice ID error that occurs when the invoice ID is not unique.
+	 * We generate a new invoice ID and patch the invoice ID in the PayPal order.
+	 * Then we retry capturing the payment.
+	 *
+	 * @param WC_Order $order Order object.
+	 * @param string   $paypal_order_id The PayPal order ID.
+	 * @param string   $action_url The action URL.
+	 * @param string   $action The action.
+	 * @return void
+	 * @throws Exception If the PayPal patch invoice_id request fails.
+	 */
+	private function handle_duplicate_invoice_id( WC_Order $order, string $paypal_order_id, string $action_url, string $action ): void {
+		$new_invoice_id = $this->generate_paypal_invoice_id_with_unique_suffix( $order );
+
+		\WC_Gateway_Paypal::log( 'Attempting to patch PayPal order invoice_id. PayPal Order ID: ' . $paypal_order_id . '. New invoice_id: ' . $new_invoice_id . '. Order ID: ' . $order->get_id() );
+
+		try {
+			$request_body = array(
+				'test_mode' => $this->gateway->testmode,
+				'order'     => array(
+					array(
+						'op'    => 'replace',
+						'path'  => "/purchase_units/@reference_id=='default'/invoice_id",
+						'value' => $new_invoice_id,
+					),
+				),
+			);
+
+			$response = $this->send_wpcom_proxy_request( 'PATCH', self::WPCOM_PROXY_ORDER_ENDPOINT . '/' . $paypal_order_id, $request_body );
+
+			if ( is_wp_error( $response ) ) {
+				throw new Exception( 'PayPal patch invoice_id request failed. Response error: ' . $response->get_error_message() );
+			}
+
+			$http_code     = wp_remote_retrieve_response_code( $response );
+			$body          = wp_remote_retrieve_body( $response );
+			$response_data = json_decode( $body, true );
+
+			if ( 200 !== $http_code && 204 !== $http_code ) {
+				\WC_Gateway_Paypal::log( 'PayPal patch invoice_id failed. Response status: ' . $http_code . '. Response body: ' . $body );
+				throw new Exception( 'Failed to patch PayPal order invoice_id. Response status: ' . $http_code );
+			}
+
+			\WC_Gateway_Paypal::log( 'Successfully patched PayPal order invoice_id. PayPal Order ID: ' . $paypal_order_id . '. New invoice_id: ' . $new_invoice_id . '. Order ID: ' . $order->get_id() );
+
+			$order->add_order_note(
+				sprintf(
+					/* translators: %1$s: New invoice ID */
+					__( 'PayPal order Invoice ID updated to %1$s to ensure uniqueness.', 'woocommerce' ),
+					esc_html( $new_invoice_id )
+				)
+			);
+			$order->save();
+
+			// Retry authorizing or capturing the payment after patching the invoice_id.
+			$this->authorize_or_capture_payment( $order, $action_url, $action, true );
+		} catch ( Exception $e ) {
+			\WC_Gateway_Paypal::log( $e->getMessage() );
+			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+			throw new Exception( $e->getMessage() );
+		}
+	}
+
+	/**
+	 * Generate a unique invoice ID for the order.
+	 *
+	 * @param WC_Order $order Order object.
+	 * @return string
+	 */
+	private function generate_paypal_invoice_id_with_unique_suffix( WC_Order $order ): string {
+		$prefix          = $this->gateway->get_option( 'invoice_prefix' );
+		$order_number    = $order->get_order_number();
+		$base_invoice_id = $prefix . $order_number;
+
+		// Generate a unique ID for the invoice.
+		$unique_id = bin2hex( random_bytes( 6 ) );
+
+		$invoice_id = $this->limit_length( $base_invoice_id . '-' . $unique_id, PayPalConstants::PAYPAL_INVOICE_ID_MAX_LENGTH );
+		return $invoice_id;
+	}
+
 	/**
 	 * Get the authorization ID for the PayPal payment.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Gateways/PayPal/RequestTest.php b/plugins/woocommerce/tests/php/src/Gateways/PayPal/RequestTest.php
index 5682e0ab258..9b9d78a1e6b 100644
--- a/plugins/woocommerce/tests/php/src/Gateways/PayPal/RequestTest.php
+++ b/plugins/woocommerce/tests/php/src/Gateways/PayPal/RequestTest.php
@@ -1411,6 +1411,184 @@ class RequestTest extends \WC_Unit_Test_Case {
 		);
 	}

+	// ========================================================================
+	// Tests for duplicate invoice ID handling (authorize_or_capture_payment)
+	// ========================================================================
+
+	/**
+	 * Test that duplicate invoice ID triggers patch and retry, and retry success completes without throwing.
+	 *
+	 * @return void
+	 */
+	public function test_authorize_or_capture_payment_handles_duplicate_invoice_id_patch_and_retry(): void {
+		$order = \WC_Helper_Order::create_order();
+		$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_ORDER_ID, 'PAYPAL_ORDER_123' );
+		$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_STATUS, PayPalConstants::STATUS_APPROVED );
+		$order->save();
+
+		$request_sequence    = array();
+		$capture_invoice_ids = array();
+		$gateway             = new \WC_Gateway_Paypal();
+		$basic_invoice_id    = $gateway->get_option( 'invoice_prefix' ) . $order->get_order_number();
+		$modified_invoice_id = null;
+
+		add_filter(
+			'pre_http_request',
+			function ( $value, $parsed_args, $url ) use ( &$request_sequence, &$capture_invoice_ids, &$modified_invoice_id, $basic_invoice_id ) {
+				if ( strpos( $url, 'transact/paypal_standard/proxy/payment/capture' ) !== false ) {
+					$request_sequence[] = 'capture';
+					// First capture: order still has basic invoice_id (set at order creation). Second capture: after PATCH, order has modified invoice_id.
+					if ( count( $request_sequence ) === 1 ) {
+						$capture_invoice_ids[] = $basic_invoice_id;
+						return array(
+							'response' => array( 'code' => 422 ),
+							'body'     => wp_json_encode(
+								array(
+									'name'     => 'UNPROCESSABLE_ENTITY',
+									'message'  => 'Duplicate invoice ID.',
+									'debug_id' => 'dup_inv',
+									'details'  => array(
+										array(
+											'issue'       => PayPalConstants::PAYPAL_ISSUE_DUPLICATE_INVOICE_ID,
+											'description' => 'Invoice ID has already been used.',
+										),
+									),
+								)
+							),
+						);
+					}
+					$capture_invoice_ids[] = $modified_invoice_id;
+					return array(
+						'response' => array( 'code' => 200 ),
+						'body'     => wp_json_encode(
+							array(
+								'id'     => 'CAPTURE_456',
+								'status' => PayPalConstants::STATUS_COMPLETED,
+							)
+						),
+					);
+				}
+				if ( strpos( $url, 'order/' ) !== false && isset( $parsed_args['method'] ) && 'PATCH' === $parsed_args['method'] ) {
+					$request_sequence[] = 'patch';
+					$body               = json_decode( $parsed_args['body'], true );
+					$this->assertArrayHasKey( 'order', $body );
+					$this->assertCount( 1, $body['order'] );
+					$this->assertSame( 'replace', $body['order'][0]['op'] );
+					$this->assertStringContainsString( 'invoice_id', $body['order'][0]['path'] );
+					$this->assertNotEmpty( $body['order'][0]['value'] );
+					$patch_invoice_id = $body['order'][0]['value'];
+					$this->assertNotEquals( $basic_invoice_id, $patch_invoice_id, 'PATCH should send modified invoice_id (with unique suffix), not the basic one that caused the duplicate.' );
+					$this->assertStringStartsWith( $basic_invoice_id . '-', $patch_invoice_id, 'PATCH invoice_id should have format: prefix + order_number + "-" + unique suffix.' );
+					$modified_invoice_id = $patch_invoice_id;
+					return array(
+						'response' => array( 'code' => 204 ),
+						'body'     => '',
+					);
+				}
+				return $value;
+			},
+			10,
+			3
+		);
+
+		$request = new PayPalRequest( new \WC_Gateway_Paypal() );
+		$request->authorize_or_capture_payment( $order, 'https://api.paypal.com/v2/checkout/orders/PAYPAL_ORDER_123/capture', PayPalConstants::PAYMENT_ACTION_CAPTURE );
+
+		remove_all_filters( 'pre_http_request' );
+
+		$this->assertSame( array( 'capture', 'patch', 'capture' ), $request_sequence );
+		$this->assertCount( 2, $capture_invoice_ids, 'Should have one invoice_id per capture call.' );
+		$this->assertSame( $basic_invoice_id, $capture_invoice_ids[0], 'First capture call should use the basic invoice_id (order creation value).' );
+		$this->assertSame( $modified_invoice_id, $capture_invoice_ids[1], 'Second capture call should use the modified invoice_id (after PATCH).' );
+		$this->assertNotEquals( $basic_invoice_id, $modified_invoice_id, 'Modified invoice_id should be different from the basic one.' );
+		$order        = wc_get_order( $order->get_id() );
+		$notes        = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+		$invoice_note = null;
+		foreach ( $notes as $note ) {
+			if ( strpos( $note->content, 'Invoice ID updated' ) !== false ) {
+				$invoice_note = $note->content;
+				break;
+			}
+		}
+		$this->assertNotNull( $invoice_note, 'Order should have a note about Invoice ID updated' );
+		$this->assertNotEquals( 'failed', $order->get_status() );
+	}
+
+	/**
+	 * Test that when retry also returns duplicate invoice ID, we do not loop and order is marked failed.
+	 *
+	 * @return void
+	 */
+	public function test_authorize_or_capture_payment_duplicate_invoice_id_on_retry_does_not_loop(): void {
+		$order = \WC_Helper_Order::create_order();
+		$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_ORDER_ID, 'PAYPAL_ORDER_123' );
+		$order->update_meta_data( PayPalConstants::PAYPAL_ORDER_META_STATUS, PayPalConstants::STATUS_APPROVED );
+		$order->save();
+
+		$capture_count = 0;
+		add_filter(
+			'pre_http_request',
+			function ( $value, $parsed_args, $url ) use ( &$capture_count ) {
+				if ( strpos( $url, 'transact/paypal_standard/proxy/payment/capture' ) !== false ) {
+					++$capture_count;
+					return array(
+						'response' => array( 'code' => 422 ),
+						'body'     => wp_json_encode(
+							array(
+								'details' => array(
+									array(
+										'issue' => PayPalConstants::PAYPAL_ISSUE_DUPLICATE_INVOICE_ID,
+									),
+								),
+							)
+						),
+					);
+				}
+				if ( strpos( $url, 'transact/paypal_standard/proxy/order/' ) !== false && isset( $parsed_args['method'] ) && 'PATCH' === $parsed_args['method'] ) {
+					return array(
+						'response' => array( 'code' => 204 ),
+						'body'     => '',
+					);
+				}
+				return $value;
+			},
+			10,
+			3
+		);
+
+		$request = new PayPalRequest( new \WC_Gateway_Paypal() );
+		$request->authorize_or_capture_payment( $order, 'https://api.paypal.com/v2/checkout/orders/PAYPAL_ORDER_123/capture', PayPalConstants::PAYMENT_ACTION_CAPTURE );
+
+		remove_all_filters( 'pre_http_request' );
+
+		// First attempt: 422 -> handle_duplicate_invoice_id -> patch -> retry. Retry: 422 again, is_retry=true so we throw (no second handle).
+		$this->assertSame( 2, $capture_count, 'Capture should be called twice (initial + retry), not more' );
+		$order = wc_get_order( $order->get_id() );
+		$this->assertEquals( 'failed', $order->get_status() );
+	}
+
+	/**
+	 * Test generate_paypal_invoice_id_with_unique_suffix format and max length.
+	 *
+	 * @return void
+	 */
+	public function test_generate_paypal_invoice_id_with_unique_suffix(): void {
+		$order = \WC_Helper_Order::create_order();
+		$order->save();
+
+		$request    = new PayPalRequest( new \WC_Gateway_Paypal() );
+		$reflection = new \ReflectionClass( $request );
+		$method     = $reflection->getMethod( 'generate_paypal_invoice_id_with_unique_suffix' );
+		$method->setAccessible( true );
+
+		$invoice_id = $method->invoke( $request, $order );
+
+		$this->assertIsString( $invoice_id );
+		$this->assertLessThanOrEqual( 127, strlen( $invoice_id ), 'Invoice ID must not exceed PayPal max length' );
+		$prefix = ( new \WC_Gateway_Paypal() )->get_option( 'invoice_prefix' );
+		$this->assertStringStartsWith( $prefix . $order->get_order_number() . '-', $invoice_id );
+	}
+
 	// ========================================================================
 	// Helper methods for capture_authorized_payment tests
 	// ========================================================================