diff --git a/changelog/woopmnt-5519-paying-for-an-already-paid-order-reuses-the-same-payment b/changelog/woopmnt-5519-paying-for-an-already-paid-order-reuses-the-same-payment new file mode 100644 index 00000000000..343960abea1 --- /dev/null +++ b/changelog/woopmnt-5519-paying-for-an-already-paid-order-reuses-the-same-payment @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Implemented amount mismatch detection for an already paid order. diff --git a/includes/class-duplicate-payment-prevention-service.php b/includes/class-duplicate-payment-prevention-service.php index 50a704793d1..61780999c8d 100644 --- a/includes/class-duplicate-payment-prevention-service.php +++ b/includes/class-duplicate-payment-prevention-service.php @@ -13,6 +13,7 @@ use WC_Payments_Order_Service; use WCPay\Constants\Intent_Status; use WCPay\Core\Server\Request\Get_Intention; +use WCPay\Exceptions\Process_Payment_Exception; if ( ! defined( 'ABSPATH' ) ) { exit; // Exit if accessed directly. @@ -74,6 +75,7 @@ public function init( WC_Payment_Gateway_WCPay $gateway, WC_Payments_Order_Servi * @param WC_Order $order Current order to check. * * @return array|void A successful response in case the attached intent was successful, null if none. + * @throws Process_Payment_Exception When order amount doesn't match the charged amount. */ public function check_payment_intent_attached_to_order_succeeded( WC_Order $order ) { $intent_id = (string) $order->get_meta( '_intent_id', true ); @@ -90,7 +92,7 @@ public function check_payment_intent_attached_to_order_succeeded( WC_Order $orde try { $request = Get_Intention::create( $intent_id ); $request->set_hook_args( $order ); - /** @var \WC_Payments_API_Abstract_Intention $intent */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort + /** @var \WC_Payments_API_Payment_Intention $intent */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort $intent = $request->send(); $intent_status = $intent->get_status(); } catch ( Exception $e ) { @@ -115,6 +117,27 @@ public function check_payment_intent_attached_to_order_succeeded( WC_Order $orde if ( Intent_Status::SUCCEEDED === $intent_status ) { $this->remove_session_processing_order( $order->get_id() ); } + + // Check if the order amount matches the charged amount. + $order_total_in_cents = \WC_Payments_Utils::prepare_amount( $order->get_total(), $order->get_currency() ); + $charged_amount = $intent->get_amount(); + + // If amounts don't match, this indicates the order was modified after payment. + // Throw an exception to prevent duplicate payment and inform the customer. + if ( $order_total_in_cents !== $charged_amount ) { + // Throw exception with customer-friendly message. + throw new Process_Payment_Exception( + sprintf( + /* translators: 1: charged amount, 2: current order total */ + __( 'This order was already paid for %1$s, but the order total has since changed to %2$s, so we prevented an overpayment. Please create a new order for any additional items.', 'woocommerce-payments' ), + wc_price( \WC_Payments_Utils::interpret_stripe_amount( $charged_amount, $order->get_currency() ), [ 'currency' => $order->get_currency() ] ), + wc_price( \WC_Payments_Utils::interpret_stripe_amount( $order_total_in_cents, $order->get_currency() ), [ 'currency' => $order->get_currency() ] ) + ), + 'duplicate_payment_amount_mismatch' + ); + } + + // Amounts match, proceed with normal status update. $this->order_service->update_order_status_from_intent( $order, $intent ); $return_url = $this->gateway->get_return_url( $order ); diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index a4278f234f2..c34a66c73c5 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -1179,7 +1179,9 @@ public function process_payment( $order_id ) { * It seems that the status only needs to change in certain instances, and within those instances the intent * information is not added to the order, as shown by tests. */ - if ( ! $blocked_by_fraud_rules && ( empty( $payment_information ) || ! $payment_information->is_changing_payment_method_for_subscription() ) ) { + if ( $e instanceof Process_Payment_Exception && 'duplicate_payment_amount_mismatch' === $e->get_error_code() ) { + $order->update_status( Order_Status::FAILED, $e->getMessage() ); + } elseif ( ! $blocked_by_fraud_rules && ( empty( $payment_information ) || ! $payment_information->is_changing_payment_method_for_subscription() ) ) { $order->update_status( Order_Status::FAILED ); } diff --git a/tests/unit/test-class-duplicate-payment-prevention-service.php b/tests/unit/test-class-duplicate-payment-prevention-service.php index 411f446cd40..0393ed285e1 100644 --- a/tests/unit/test-class-duplicate-payment-prevention-service.php +++ b/tests/unit/test-class-duplicate-payment-prevention-service.php @@ -9,6 +9,7 @@ use WCPay\Constants\Order_Status; use WCPay\Core\Server\Request\Get_Intention; use WCPay\Duplicate_Payment_Prevention_Service; +use WCPay\Exceptions\Process_Payment_Exception; /** * WCPay\Duplicate_Payment_Prevention_Service unit tests. @@ -278,4 +279,55 @@ public function provider_check_payment_intent_attached_to_order_succeeded_return return $ret; } + + /** + * Test that when duplicate payment is prevented with amount mismatch, + * an exception is thrown to inform the customer. + * + * This reproduces the issue from WOOPMNT-5519 where admins see misleading order notes + * suggesting a new charge was made when duplicate prevention kicked in. + */ + public function test_check_payment_intent_attached_to_order_succeeded_with_amount_mismatch() { + $attached_intent_id = 'pi_attached_intent_id'; + $attached_charge_id = 'ch_attached_charge_id'; + $original_amount = 1000; // $10.00 in cents. + $updated_amount = 1500; // $15.00 in cents. + + // Arrange order that was already paid at $10. + $order = WC_Helper_Order::create_order(); + $order->update_meta_data( '_intent_id', $attached_intent_id ); + $order->set_total( $original_amount / 100 ); // Original amount. + $order->save(); + $order_id = $order->get_id(); + + // Simulate admin changing the order total (adding items). + $order->set_total( $updated_amount / 100 ); // Updated amount. + $order->set_status( 'pending' ); // Admin changed status to add items. + $order->save(); + + // Arrange mock get_intention with the original $10 charge. + $attached_intent = WC_Helper_Intention::create_intention( + [ + 'id' => $attached_intent_id, + 'status' => Intent_Status::SUCCEEDED, + 'metadata' => [ 'order_id' => $order_id ], + 'amount' => $original_amount, + 'charge' => [ + 'id' => $attached_charge_id, + 'amount' => $original_amount, + ], + ] + ); + + $this->mock_wcpay_request( Get_Intention::class, 1, $attached_intent_id ) + ->expects( $this->once() ) + ->method( 'format_response' ) + ->willReturn( $attached_intent ); + + // Act & Assert: Exception should be thrown when amount mismatch is detected. + $this->expectException( Process_Payment_Exception::class ); + $this->expectExceptionMessage( 'This order was already paid for' ); + + $this->service->check_payment_intent_attached_to_order_succeeded( $order ); + } }