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: 23 additions & 2 deletions includes/class-duplicate-payment-prevention-service.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,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,7 +115,28 @@ 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() );
}
$this->order_service->update_order_status_from_intent( $order, $intent );

// Check if the order amount matches the charged amount.
$order_total_in_cents = (int) ( $order->get_total() * 100 );
$charge = $intent->get_charge();
$charged_amount = $charge ? $charge->get_amount() : 0;

// If amounts don't match, this indicates the order was modified after payment.
// Add a note explaining the situation instead of marking it as paid.
if ( $order_total_in_cents !== $charged_amount ) {
$order->add_order_note(
sprintf(
/* translators: 1: payment intent ID, 2: charged amount, 3: current order total */
__( 'Duplicate payment attempt prevented. Order was already paid with payment intent %1$s for %2$s, but current order total is %3$s. Please review the order and create a new order for any additional items.', 'woocommerce-payments' ),
$intent_id,
wc_price( $charged_amount / 100, [ 'currency' => $order->get_currency() ] ),
wc_price( $order->get_total(), [ 'currency' => $order->get_currency() ] )
)
);
} else {
// 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 );
$return_url = add_query_arg( self::FLAG_PREVIOUS_SUCCESSFUL_INTENT, 'yes', $return_url );
Expand Down
78 changes: 78 additions & 0 deletions tests/unit/test-class-duplicate-payment-prevention-service.php
Original file line number Diff line number Diff line change
Expand Up @@ -278,4 +278,82 @@ public function provider_check_payment_intent_attached_to_order_succeeded_return

return $ret;
}

/**
* Test that when duplicate payment is prevented with amount mismatch,
* the order note should indicate the mismatch and not mark order as completed.
*
* 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';
$return_url = 'https://example.com';
$original_amount = 1000; // $10.00 in cents.
$updated_amount = 1500; // $15.00 in cents.

// Arrange the redirect URL.
$this->mock_gateway
->expects( $this->once() )
->method( 'get_return_url' )
->willReturn( $return_url );

// 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: Check for duplicate payment.
$result = $this->service->check_payment_intent_attached_to_order_succeeded( $order );

// Assert: Redirect is returned (duplicate prevention worked).
$this->assertSame( 'success', $result['result'] );
$this->assertStringContainsString( $return_url, $result['redirect'] );
$this->assertStringContainsString( Duplicate_Payment_Prevention_Service::FLAG_PREVIOUS_SUCCESSFUL_INTENT, $result['redirect'] );

// Reload order to check final state.
$order = wc_get_order( $order_id );

// Assert: Order should NOT be marked as completed (it's underpaid).
$this->assertNotEquals( 'completed', $order->get_status(), 'Order should not be marked completed when amount mismatch exists' );
$this->assertNotEquals( 'processing', $order->get_status(), 'Order should not be marked processing when amount mismatch exists' );

// Assert: Order notes should indicate amount mismatch.
$notes = wc_get_order_notes( [ 'order_id' => $order_id ] );
$latest_note = $notes[0]->content ?? '';

// The note should NOT claim a payment was "successfully charged" for the full amount.
$this->assertStringNotContainsString( '$15', $latest_note, 'Order note should not show updated amount as charged' );

// The note SHOULD indicate duplicate payment was prevented with amount info.
$this->assertStringContainsString( 'prevented', strtolower( $latest_note ), 'Order note should indicate duplicate payment was prevented' );
$this->assertStringContainsString( $attached_intent_id, $latest_note, 'Order note should reference the payment intent ID' );
}
}
Loading