--- a/another-wordpress-classifieds-plugin/awpcp.php
+++ b/another-wordpress-classifieds-plugin/awpcp.php
@@ -5,7 +5,7 @@
* Plugin Name: AWP Classifieds
* Plugin URI: https://awpcp.com/
* Description: Run a free or paid classified ads service on your WordPress site.
- * Version: 4.4.3
+ * Version: 4.4.4
* Author: AWP Classifieds Team
* Author URI: https://awpcp.com/
* License: GPLv2 or later
@@ -60,7 +60,7 @@
global $hasregionsmodule;
global $hasextrafieldsmodule;
-$awpcp_db_version = '4.4.3';
+$awpcp_db_version = '4.4.4';
$awpcp_imagesurl = AWPCP_URL . '/resources/images';
$hascaticonsmodule = 0;
--- a/another-wordpress-classifieds-plugin/frontend/page-place-ad.php
+++ b/another-wordpress-classifieds-plugin/frontend/page-place-ad.php
@@ -1201,6 +1201,9 @@
protected function prepare_ad_details($details, $characters) {
$allow_html = (bool) get_awpcp_option('allowhtmlinadtext');
+ // Strip shortcodes to prevent execution of [embed] and other shortcodes in user-submitted content (SSRF prevention).
+ $details = strip_shortcodes( $details );
+
if (!$allow_html) {
$details = wp_strip_all_tags( $details );
} else {
--- a/another-wordpress-classifieds-plugin/frontend/templates/payments-payment-completed-page.tpl.php
+++ b/another-wordpress-classifieds-plugin/frontend/templates/payments-payment-completed-page.tpl.php
@@ -18,7 +18,7 @@
<input type="hidden" value="<?php echo esc_attr($value) ?>" name="<?php echo esc_attr($name) ?>">
<?php endforeach ?>
- <?php if ($success): ?>
+ <?php if ( $success && empty( $pending_verification ) ) : ?>
<p class="awpcp-form-submit">
<input class="button" type="submit" value="<?php esc_attr_e( 'Continue', 'another-wordpress-classifieds-plugin' ); ?>" id="submit" name="submit">
</p>
--- a/another-wordpress-classifieds-plugin/frontend/widget-categories.php
+++ b/another-wordpress-classifieds-plugin/frontend/widget-categories.php
@@ -7,9 +7,30 @@
class AWPCP_CategoriesWidget extends WP_Widget {
+ private static $translated = false;
public function __construct() {
- $description = __( 'Displays a list of Ad categories.', 'another-wordpress-classifieds-plugin');
- parent::__construct( 'awpcp-categories', __( 'AWPCP Categories', 'another-wordpress-classifieds-plugin' ), array('description' => $description));
+ parent::__construct(
+ 'awpcp-categories',
+ 'AWPCP Categories',
+ array( 'description' => 'Displays a list of Ad categories.' )
+ );
+
+ if ( ! self::$translated ) {
+ add_action( 'admin_init', [ $this, 'set_translated_strings' ] );
+ self::$translated = true;
+ }
+ }
+
+ /**
+ * Sets translated widget name and description after translations are loaded.
+ *
+ * @since 4.4.4
+ *
+ * @return void
+ */
+ public function set_translated_strings() {
+ $this->name = __( 'AWPCP Categories', 'another-wordpress-classifieds-plugin' );
+ $this->widget_options['description'] = __( 'Displays a list of Ad categories.', 'another-wordpress-classifieds-plugin' );
}
protected function defaults() {
--- a/another-wordpress-classifieds-plugin/frontend/widget-latest-ads.php
+++ b/another-wordpress-classifieds-plugin/frontend/widget-latest-ads.php
@@ -12,19 +12,42 @@
*/
class AWPCP_LatestAdsWidget extends WP_Widget {
+ private static $translated = false;
protected $listing_renderer;
protected $attachment_properties;
protected $attachments;
- public function __construct($id=null, $name=null, $description=null) {
- $id = is_null($id) ? 'awpcp-latest-ads': $id;
- $name = is_null($name) ? __( 'AWPCP Latest Ads', 'another-wordpress-classifieds-plugin') : $name;
- $description = is_null($description) ? __( 'Displays a list of latest Ads', 'another-wordpress-classifieds-plugin') : $description;
- parent::__construct($id, $name, array('description' => $description));
+ public function __construct( $id = null, $name = null, $description = null ) {
+ $id = is_null( $id ) ? 'awpcp-latest-ads' : $id;
+ $name = is_null( $name ) ? 'AWPCP Latest Ads' : $name;
+ $description = is_null( $description ) ? 'Displays a list of latest Ads' : $description;
- $this->listing_renderer = awpcp_listing_renderer();
+ parent::__construct(
+ $id,
+ $name,
+ array( 'description' => $description )
+ );
+
+ if ( ! self::$translated ) {
+ add_action( 'admin_init', [ $this, 'set_translated_strings' ] );
+ self::$translated = true;
+ }
+
+ $this->listing_renderer = awpcp_listing_renderer();
$this->attachment_properties = awpcp_attachment_properties();
- $this->attachments = awpcp_attachments_collection();
+ $this->attachments = awpcp_attachments_collection();
+ }
+
+ /**
+ * Sets translated widget name and description after translations are loaded.
+ *
+ * @since 4.4.4
+ *
+ * @return void
+ */
+ public function set_translated_strings() {
+ $this->name = __( 'AWPCP Latest Ads', 'another-wordpress-classifieds-plugin' );
+ $this->widget_options['description'] = __( 'Displays a list of latest Ads', 'another-wordpress-classifieds-plugin' );
}
protected function defaults() {
--- a/another-wordpress-classifieds-plugin/frontend/widget-random-ad.php
+++ b/another-wordpress-classifieds-plugin/frontend/widget-random-ad.php
@@ -12,12 +12,31 @@
*/
class AWPCP_RandomAdWidget extends AWPCP_LatestAdsWidget {
+ private static $random_translated = false;
+
public function __construct() {
parent::__construct(
'awpcp-random-ads',
- __( 'AWPCP Random Ads', 'another-wordpress-classifieds-plugin' ),
- __( 'Displays a list of random Ads', 'another-wordpress-classifieds-plugin' )
+ 'AWPCP Random Ads',
+ 'Displays a list of random Ads'
);
+
+ if ( ! self::$random_translated ) {
+ add_action( 'admin_init', [ $this, 'set_random_translated_strings' ] );
+ self::$random_translated = true;
+ }
+ }
+
+ /**
+ * Sets translated widget name and description after translations are loaded.
+ *
+ * @since 4.4.4
+ *
+ * @return void
+ */
+ public function set_random_translated_strings() {
+ $this->name = __( 'AWPCP Random Ads', 'another-wordpress-classifieds-plugin' );
+ $this->widget_options['description'] = __( 'Displays a list of random Ads', 'another-wordpress-classifieds-plugin' );
}
protected function defaults() {
--- a/another-wordpress-classifieds-plugin/frontend/widget-search.php
+++ b/another-wordpress-classifieds-plugin/frontend/widget-search.php
@@ -9,8 +9,29 @@
class AWPCP_Search_Widget extends WP_Widget {
+ private static $translated = false;
public function __construct() {
- parent::__construct(false, __( 'AWPCP Search Ads', 'another-wordpress-classifieds-plugin'));
+ parent::__construct(
+ false,
+ 'AWPCP Search Ads',
+ []
+ );
+
+ if ( ! self::$translated ) {
+ add_action( 'admin_init', [ $this, 'set_translated_strings' ] );
+ self::$translated = true;
+ }
+ }
+
+ /**
+ * Sets translated widget name and description after translations are loaded.
+ *
+ * @since 4.4.4
+ *
+ * @return void
+ */
+ public function set_translated_strings() {
+ $this->name = __( 'AWPCP Search Ads', 'another-wordpress-classifieds-plugin' );
}
/**
--- a/another-wordpress-classifieds-plugin/functions.php
+++ b/another-wordpress-classifieds-plugin/functions.php
@@ -1589,6 +1589,8 @@
* @access private
*/
function awpcp_get_formmatted_amount( $value, $template ) {
+ $value = (float) $value;
+
if ( $value < 0 ) {
return '(' . str_replace( '<amount>', awpcp_format_number( $value ), $template ) . ')';
} else {
--- a/another-wordpress-classifieds-plugin/includes/class-awpcp.php
+++ b/another-wordpress-classifieds-plugin/includes/class-awpcp.php
@@ -216,7 +216,7 @@
// actions and filters from functions_awpcp.php
add_action('phpmailer_init','awpcp_phpmailer_init_smtp');
- add_action('widgets_init', array($this, 'register_widgets'));
+ add_action( 'widgets_init', array( $this, 'register_widgets' ) );
awpcp_schedule_activation();
@@ -1133,6 +1133,14 @@
}
wp_register_script(
+ 'moment',
+ "$vendors/moment-2.22.2/moment-with-locales.min.js",
+ [],
+ '2.22.2',
+ true
+ );
+
+ wp_register_script(
'daterangepicker',
"$vendors/daterangepicker/daterangepicker.min.js",
[ 'jquery', 'moment' ],
@@ -1142,7 +1150,7 @@
wp_register_style(
'daterangepicker',
- "$vendors/daterangepicker.min.css",
+ "$vendors/daterangepicker/daterangepicker.min.css",
[],
'3.0.3'
);
--- a/another-wordpress-classifieds-plugin/includes/class-categories-list-cache.php
+++ b/another-wordpress-classifieds-plugin/includes/class-categories-list-cache.php
@@ -32,6 +32,7 @@
*/
public function clear() {
$transient_keys = get_option( 'awpcp-categories-list-cache-keys', array() );
+ $transient_keys = is_array( $transient_keys ) ? $transient_keys : array();
foreach ( $transient_keys as $transient_key ) {
delete_transient( $transient_key );
--- a/another-wordpress-classifieds-plugin/includes/compatibility/class-plugin-integrations.php
+++ b/another-wordpress-classifieds-plugin/includes/compatibility/class-plugin-integrations.php
@@ -33,7 +33,9 @@
}
public function get_enabled_plugin_integrations() {
- return get_option( 'awpcp_plugin_integrations', array() );
+ $integrations = get_option( 'awpcp_plugin_integrations', array() );
+
+ return is_array( $integrations ) ? $integrations : array();
}
public function maybe_disable_plugin_integration( $plugin ) {
@@ -65,11 +67,17 @@
public function discover_supported_plugin_integrations() {
delete_option( 'awpcp_plugin_integrations' );
- foreach ( get_option( 'active_plugins', array() ) as $plugin ) {
+ $active_plugins = get_option( 'active_plugins', array() );
+ $active_plugins = is_array( $active_plugins ) ? $active_plugins : array();
+
+ foreach ( $active_plugins as $plugin ) {
$this->maybe_enable_plugin_integration( $plugin, false );
}
- foreach ( get_option( 'active_sitewide_plugins', array() ) as $plugin ) {
+ $sitewide_plugins = get_option( 'active_sitewide_plugins', array() );
+ $sitewide_plugins = is_array( $sitewide_plugins ) ? $sitewide_plugins : array();
+
+ foreach ( $sitewide_plugins as $plugin ) {
$this->maybe_enable_plugin_integration( $plugin, true );
}
}
--- a/another-wordpress-classifieds-plugin/includes/frontend/class-frontend-container-configuration.php
+++ b/another-wordpress-classifieds-plugin/includes/frontend/class-frontend-container-configuration.php
@@ -190,6 +190,7 @@
return new AWPCP_GenerateListingPreviewAjaxHandler(
$container['ListingsContentRenderer'],
$container['ListingsCollection'],
+ $container['ListingAuthorization'],
awpcp_ajax_response(),
$container['Request']
);
--- a/another-wordpress-classifieds-plugin/includes/frontend/class-generate-listing-preview-ajax-handler.php
+++ b/another-wordpress-classifieds-plugin/includes/frontend/class-generate-listing-preview-ajax-handler.php
@@ -23,6 +23,11 @@
private $listings;
/**
+ * @var AWPCP_ListingAuthorization
+ */
+ private $authorization;
+
+ /**
* @var AWPCP_Request
*/
private $request;
@@ -30,11 +35,12 @@
/**
* @since 4.0.0
*/
- public function __construct( $listings_content_renderer, $listings, $response, $request ) {
+ public function __construct( $listings_content_renderer, $listings, $authorization, $response, $request ) {
parent::__construct( $response );
$this->listings_content_renderer = $listings_content_renderer;
$this->listings = $listings;
+ $this->authorization = $authorization;
$this->request = $request;
}
@@ -45,11 +51,52 @@
* @since 4.0.0
*/
public function ajax() {
+ try {
+ return $this->try_to_generate_listing_preview();
+ } catch ( AWPCP_Exception $e ) {
+ return $this->multiple_errors_response( $e->getMessage() );
+ }
+ }
+
+ /**
+ * Generates the listing preview after verifying authorization.
+ *
+ * @since 4.4.4
+ * @throws AWPCP_Exception If authorization fails.
+ */
+ private function try_to_generate_listing_preview() {
$listing_id = $this->request->post( 'ad_id' );
$listing = $this->listings->get( $listing_id );
- $content = apply_filters( 'the_content', $listing->post_content );
- $preview = $this->listings_content_renderer->render_content_without_notices( $content, $listing );
+ $nonce = $this->request->post( 'nonce' );
+
+ if ( ! wp_verify_nonce( $nonce, "awpcp-save-listing-information-{$listing->ID}" ) ) {
+ throw new AWPCP_Exception( esc_html__( 'You are not authorized to perform this action.', 'another-wordpress-classifieds-plugin' ) );
+ }
+
+ if ( ! $this->is_current_user_allowed_to_preview_listing( $listing ) ) {
+ throw new AWPCP_Exception( esc_html__( 'You are not authorized to perform this action.', 'another-wordpress-classifieds-plugin' ) );
+ }
+
+ $content = apply_filters( 'the_content', $listing->post_content );
+ $preview = $this->listings_content_renderer->render_content_without_notices( $content, $listing );
return $this->success( [ 'preview' => $preview ] );
}
+
+ /**
+ * Checks whether the current user is allowed to preview the listing.
+ *
+ * @since 4.4.4
+ *
+ * @param WP_Post $listing The listing post object.
+ *
+ * @return bool
+ */
+ private function is_current_user_allowed_to_preview_listing( $listing ) {
+ if ( is_user_logged_in() ) {
+ return $this->authorization->is_current_user_allowed_to_edit_listing( $listing );
+ }
+
+ return 'auto-draft' === $listing->post_status;
+ }
}
--- a/another-wordpress-classifieds-plugin/includes/frontend/class-listing-posted-data.php
+++ b/another-wordpress-classifieds-plugin/includes/frontend/class-listing-posted-data.php
@@ -178,6 +178,9 @@
private function prepare_content( $content, $characters_allowed ) {
$allow_html = (bool) get_awpcp_option( 'allowhtmlinadtext' );
+ // Strip shortcodes to prevent execution of [embed] and other shortcodes in user-submitted content (SSRF prevention).
+ $content = strip_shortcodes( $content );
+
if ( $allow_html ) {
$content = wp_kses_post( $content );
} else {
--- a/another-wordpress-classifieds-plugin/includes/functions/payments.php
+++ b/another-wordpress-classifieds-plugin/includes/functions/payments.php
@@ -8,24 +8,113 @@
* Verify data received from PayPal IPN notifications and returns PayPal's
* response.
*
+ * PayPal requires byte-for-byte verification, so we read the raw POST body
+ * from php://input and send it back exactly as received, prepended with
+ * cmd=_notify-validate.
+ *
* Request errors, if any, are returned by reference.
*
* @since 2.0.7
*
- * @return string VERIFIED, INVALID or ERROR
+ * @param array $data Deprecated. No longer used. Raw body is read from php://input.
+ * @param array $errors Request errors, returned by reference.
+ * @return string VERIFIED, INVALID or ERROR.
*/
-function awpcp_paypal_verify_received_data($data=array(), &$errors=array()) {
- $content = 'cmd=_notify-validate';
- foreach ($data as $key => $value) {
- $value = rawurlencode(stripslashes($value));
- $content .= "&$key=$value";
+function awpcp_paypal_verify_received_data( $data = array(), &$errors = array() ) {
+ // Read the raw POST body exactly as PayPal sent it.
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ $raw_post_body = file_get_contents( 'php://input' );
+
+ // If no raw body, this might be a connectivity test from the debug page.
+ if ( empty( $raw_post_body ) ) {
+ return awpcp_paypal_test_connection( $errors );
}
- // Use WordPress HTTP API for all verification requests
+ // Prepend the validation command to the exact bytes received.
+ $content = 'cmd=_notify-validate&' . $raw_post_body;
+
+ // Use WordPress HTTP API for verification requests.
+ return awpcp_paypal_verify_received_data_with_wp_http( $content, $errors );
+}
+
+/**
+ * Test PayPal IPN endpoint connectivity.
+ *
+ * Sends a minimal request to PayPal's IPN endpoint to verify the server
+ * can communicate with PayPal. Used by the debug page.
+ *
+ * @since 4.4.4
+ *
+ * @param array $errors Request errors, returned by reference.
+ * @return string INVALID if connection works (expected response), ERROR otherwise.
+ */
+function awpcp_paypal_test_connection( &$errors = array() ) {
+ // Send a minimal validation request to test connectivity.
+ // PayPal will return INVALID since there's no real transaction, but that confirms connectivity.
+ $content = 'cmd=_notify-validate';
+
return awpcp_paypal_verify_received_data_with_wp_http( $content, $errors );
}
/**
+ * Verify data received from PayPal IPN using the WordPress HTTP API.
+ *
+ * This function was added to replace the legacy functions
+ * awpcp_paypal_verify_received_data_with_curl() and
+ * awpcp_paypal_verify_received_data_with_fsockopen().
+ *
+ * @since 4.4.4
+ *
+ * @param string $postfields IPN request payload.
+ * @param array $errors Request errors, returned by reference.
+ * @return string VERIFIED, INVALID or ERROR.
+ */
+function awpcp_paypal_verify_received_data_with_wp_http( $postfields = '', &$errors = array() ) {
+ $is_test_mode_enabled = intval( get_awpcp_option( 'paylivetestmode' ) ) === 1;
+
+ $paypal_url = $is_test_mode_enabled
+ ? 'https://ipnpb.sandbox.paypal.com/cgi-bin/webscr'
+ : 'https://ipnpb.paypal.com/cgi-bin/webscr';
+
+ $args = array(
+ 'method' => 'POST',
+ 'timeout' => 30,
+ 'redirection' => 5,
+ 'httpversion' => '1.1',
+ 'blocking' => true,
+ 'headers' => array(
+ 'Content-Type' => 'application/x-www-form-urlencoded',
+ 'Connection' => 'close',
+ ),
+ 'body' => $postfields,
+ 'cookies' => array(),
+ 'sslverify' => true,
+ );
+
+ $response = wp_remote_post( $paypal_url, $args );
+
+ if ( is_wp_error( $response ) ) {
+ $errors = array_merge( $errors, $response->get_error_messages() );
+ return 'ERROR';
+ }
+
+ $response_code = wp_remote_retrieve_response_code( $response );
+ if ( 200 !== $response_code ) {
+ $errors[] = sprintf( 'HTTP %d: %s', $response_code, wp_remote_retrieve_response_message( $response ) );
+ return 'ERROR';
+ }
+
+ $response_body = wp_remote_retrieve_body( $response );
+ $response_body = trim( $response_body );
+
+ if ( in_array( $response_body, array( 'VERIFIED', 'INVALID' ), true ) ) {
+ return $response_body;
+ }
+
+ return 'ERROR';
+}
+
+/**
* Validate the data received from PayFast.
*
* @since 3.7.8
@@ -38,7 +127,7 @@
continue;
}
- $content .= $key . '=' . rawurlencode( stripslashes( $value ) ) . '&';
+ $content .= $key . '=' . urlencode( stripslashes( $value ) ) . '&';
}
$content = rtrim( $content, '&' );
--- a/another-wordpress-classifieds-plugin/includes/listings/class-listings-content.php
+++ b/another-wordpress-classifieds-plugin/includes/listings/class-listings-content.php
@@ -71,6 +71,16 @@
return $content;
}
+ /**
+ * Allow disabling the rendering of shortcodes in listings content.
+ * We run it after the single listing check to avoid unnecessary callbacks.
+ *
+ * @since 4.4.4
+ */
+ if ( apply_filters( 'awpcp_disable_listing_shortcode_stripping', false ) ) {
+ return $content;
+ }
+
return strip_shortcodes($content);
}
--- a/another-wordpress-classifieds-plugin/includes/payment-gateway-paypal-standard.php
+++ b/another-wordpress-classifieds-plugin/includes/payment-gateway-paypal-standard.php
@@ -52,8 +52,8 @@
$verified = $transaction->get( 'verified', false );
// phpcs:ignore WordPress.Security.NonceVerification
if ( ! empty( $_POST ) ) {
- // phpcs:ignore WordPress.Security.NonceVerification
- $response = awpcp_paypal_verify_received_data( $_POST, $errors );
+ // Verification reads raw body from php://input for byte-for-byte accuracy.
+ $response = awpcp_paypal_verify_received_data( array(), $errors );
$verified = strcasecmp( $response, 'VERIFIED' ) === 0;
}
@@ -63,27 +63,33 @@
$url = awpcp_current_url();
if ( $variables <= 0 ) {
- /* translators: %s link url. */
+ /* translators: %1$s and %2$s are the current URL. */
$message = __( "We haven't received your payment information from PayPal yet and we are unable to verify your transaction. Please reload this page or visit <a href="%1$s">%2$s</a> in 30 seconds to continue placing your Ad.", 'another-wordpress-classifieds-plugin' );
$errors[] = sprintf( $message, $url, $url );
- } else {
- /* translators: %s status %d variables count. */
- $message = __( 'PayPal returned the following status from your payment: %1$s. %2$d payment variables were posted.', 'another-wordpress-classifieds-plugin' );
- $errors[] = sprintf( $message, $response, $variables );
- $errors[] = __( 'If this status is not COMPLETED or VERIFIED, then you may need to wait a bit before your payment is approved, or contact PayPal directly as to the reason the payment is having a problem.', 'another-wordpress-classifieds-plugin' );
- }
- $errors[] = __( 'If you have any further questions, please contact this site administrator.', 'another-wordpress-classifieds-plugin' );
-
- if ( $variables <= 0 ) {
$transaction->errors['verification-get'] = $errors;
- } else {
+ } elseif ( 'INVALID' === $response ) {
+ // INVALID on user return is likely a timing issue. Show pending message.
+ $transaction->set( 'pending_verification', true );
+
+ // Don't set errors - we'll show a pending notice instead.
+ unset( $transaction->errors['verification-get'] );
+ unset( $transaction->errors['verification-post'] );
+ } elseif ( 'ERROR' === $response ) {
+ // ERROR means something went wrong with the verification request itself.
+ $errors[] = __( 'There was an error verifying your payment with PayPal. Please wait a moment and refresh this page.', 'another-wordpress-classifieds-plugin' );
+ $errors[] = __( 'If you have any further questions, please contact this site administrator.', 'another-wordpress-classifieds-plugin' );
+
$transaction->errors['verification-post'] = $errors;
+
+ // Clear pending verification flag to avoid stale state.
+ $transaction->set( 'pending_verification', false );
}
} else {
- // clean up previous errors.
+ // Clean up previous errors and pending state.
unset( $transaction->errors['verification-get'] );
unset( $transaction->errors['verification-post'] );
+ $transaction->set( 'pending_verification', false );
}
$txn_id = awpcp_get_var( array( 'param' => 'txn_id' ), 'post' );
@@ -273,6 +279,22 @@
}
public function process_payment_completed( $transaction ) {
+ $this->do_process_payment( $transaction, false );
+ }
+
+ public function process_payment_notification( $transaction ) {
+ $this->do_process_payment( $transaction, true );
+ }
+
+ /**
+ * Process the payment verification.
+ *
+ * @since 4.4.4
+ *
+ * @param AWPCP_Payment_Transaction $transaction The payment transaction.
+ * @param bool $is_ipn Whether this is an IPN notification.
+ */
+ private function do_process_payment( $transaction, $is_ipn ) {
if ( $transaction->get( 'verified', false ) ) {
return;
}
@@ -284,15 +306,28 @@
$response = $this->verify_transaction( $transaction );
$transaction->payment_status = AWPCP_Payment_Transaction::PAYMENT_STATUS_UNKNOWN;
+
if ( 'VERIFIED' === $response ) {
$this->validate_transaction( $transaction );
- } elseif ( 'INVALID' === $response ) {
- $transaction->payment_status = AWPCP_Payment_Transaction::PAYMENT_STATUS_INVALID;
+ return;
}
- }
- public function process_payment_notification( $transaction ) {
- $this->process_payment_completed( $transaction );
+ if ( 'INVALID' === $response ) {
+ if ( $is_ipn ) {
+ // IPN returning INVALID is a real failure from PayPal.
+ $transaction->payment_status = AWPCP_Payment_Transaction::PAYMENT_STATUS_INVALID;
+ } else {
+ // User return with INVALID is likely a timing issue. Set to PENDING and wait for IPN.
+ $transaction->payment_status = AWPCP_Payment_Transaction::PAYMENT_STATUS_PENDING;
+ $transaction->set( 'pending_verification', true );
+ }
+ } elseif ( 'ERROR' === $response ) {
+ // ERROR means the verification request itself failed (network, server issue, etc.).
+ // Clear pending verification to avoid showing stale pending UI.
+ $transaction->set( 'pending_verification', false );
+
+ // Keep status as UNKNOWN - user can retry by refreshing.
+ }
}
public function process_payment_canceled( $transaction ) {
--- a/another-wordpress-classifieds-plugin/includes/payments-api.php
+++ b/another-wordpress-classifieds-plugin/includes/payments-api.php
@@ -927,10 +927,20 @@
}
public function render_payment_completed_page($transaction, $action='', $hidden=array()) {
- $success = false;
- $text = '';
+ $success = false;
+ $text = '';
+ $pending_verification = $transaction->get( 'pending_verification', false );
+
+ if ( $pending_verification && $transaction->payment_is_pending() ) {
+ // Payment verification returned INVALID on user return - likely a timing issue.
+ // Show a friendly pending message and auto-refresh to wait for IPN.
+ $title = __( 'Verifying Your Payment', 'another-wordpress-classifieds-plugin' );
+ $text = __( 'Your payment is being verified with PayPal. This usually takes just a few seconds. The page will refresh automatically.', 'another-wordpress-classifieds-plugin' );
+ $success = true;
- if ($transaction->payment_is_completed() || $transaction->payment_is_pending()) {
+ // Add auto-refresh script.
+ add_action( 'wp_footer', array( $this, 'add_pending_verification_refresh_script' ) );
+ } elseif ( $transaction->payment_is_completed() || $transaction->payment_is_pending() ) {
$title = __( 'Payment Completed', 'another-wordpress-classifieds-plugin');
if ($transaction->payment_is_completed())
@@ -940,6 +950,9 @@
$success = true;
+ // Clear the refresh counter since verification completed.
+ add_action( 'wp_footer', array( $this, 'add_clear_pending_verification_script' ) );
+
} elseif ($transaction->payment_is_not_required()) {
$title = __( 'Payment Not Required', 'another-wordpress-classifieds-plugin');
$text = __( 'No Payment is required for this transaction. Please press the button below to continue with the process.', 'another-wordpress-classifieds-plugin');
@@ -994,7 +1007,11 @@
}
public function render_payment_completed_page_title($transaction) {
- if ($transaction->was_payment_successful()) {
+ $pending_verification = $transaction->get( 'pending_verification', false );
+
+ if ( $pending_verification && $transaction->payment_is_pending() ) {
+ return __( 'Verifying Your Payment', 'another-wordpress-classifieds-plugin' );
+ } elseif ($transaction->was_payment_successful()) {
return __( 'Payment Completed', 'another-wordpress-classifieds-plugin');
} elseif ($transaction->payment_is_canceled()) {
return __( 'Payment Canceled', 'another-wordpress-classifieds-plugin');
@@ -1004,4 +1021,57 @@
return __( 'Payment Failed', 'another-wordpress-classifieds-plugin');
}
}
+
+ /**
+ * Output JavaScript to auto-refresh the page while waiting for payment verification.
+ *
+ * @since 4.4.4
+ */
+ public function add_pending_verification_refresh_script() {
+ ?>
+ <script>
+ (function() {
+ const storageKey = 'awpcp_pending_verification_refresh_count';
+ const maxRefreshes = 6;
+ const refreshInterval = 5000; // 5 seconds
+
+ // Get the current count from sessionStorage, defaulting to 0.
+ let refreshCount = parseInt( sessionStorage.getItem( storageKey ) || '0', 10 );
+
+ function refreshPage() {
+ refreshCount++;
+
+ if ( refreshCount <= maxRefreshes ) {
+ // Save the incremented count before reloading.
+ sessionStorage.setItem( storageKey, refreshCount.toString() );
+ window.location.reload();
+ } else {
+ // Max refreshes reached, clear the counter.
+ sessionStorage.removeItem( storageKey );
+ }
+ }
+
+ setTimeout( refreshPage, refreshInterval );
+ })();
+ </script>
+ <?php
+ }
+
+ /**
+ * Output JavaScript to clear the pending verification refresh counter.
+ *
+ * Called when payment verification completes successfully to stop any
+ * further auto-refreshes.
+ *
+ * @since 4.4.4
+ */
+ public function add_clear_pending_verification_script() {
+ ?>
+ <script>
+ (function() {
+ sessionStorage.removeItem( 'awpcp_pending_verification_refresh_count' );
+ })();
+ </script>
+ <?php
+ }
}