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
// ========================================================================