Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: fix

Implemented amount mismatch detection for an already paid order.
25 changes: 24 additions & 1 deletion includes/class-duplicate-payment-prevention-service.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 );
Expand All @@ -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 ) {
Expand All @@ -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 );
Expand Down
4 changes: 3 additions & 1 deletion includes/class-wc-payment-gateway-wcpay.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}

Expand Down
52 changes: 52 additions & 0 deletions tests/unit/test-class-duplicate-payment-prevention-service.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 );
}
}
Loading