diff --git a/inc/models/class-payment.php b/inc/models/class-payment.php
index 082168c53..539df7d69 100644
--- a/inc/models/class-payment.php
+++ b/inc/models/class-payment.php
@@ -800,7 +800,11 @@ public function get_payment_url() {
$args['checkout_form'] = 'wu-pay-invoice';
}
- return add_query_arg($args, wu_get_registration_url());
+ $payment_url = add_query_arg($args, wu_get_registration_url());
+
+ $magic_link = \WP_Ultimo\SSO\Magic_Link::get_instance()->generate_payment_magic_link($this, $payment_url);
+
+ return $magic_link ?: $payment_url;
}
/**
diff --git a/inc/sso/class-magic-link.php b/inc/sso/class-magic-link.php
index f5a9eded3..caf6468c8 100644
--- a/inc/sso/class-magic-link.php
+++ b/inc/sso/class-magic-link.php
@@ -48,6 +48,14 @@ class Magic_Link {
*/
const TOKEN_EXPIRATION = 600;
+ /**
+ * Claim expiration time in seconds.
+ *
+ * @since 2.16.2
+ * @var int
+ */
+ const CLAIM_EXPIRATION = 30;
+
/**
* Initialize hooks.
*
@@ -152,6 +160,64 @@ public function generate_magic_link($user_id, $site_id, $redirect_to = '') {
return apply_filters('wu_magic_link_url', $magic_link, $user_id, $site_id, $redirect_to);
}
+ /**
+ * Generate a magic link for a customer to pay an outstanding payment.
+ *
+ * Payment links target the network main site, where customers are not
+ * necessarily site members. Access is therefore bound to payment ownership
+ * rather than normal site membership.
+ *
+ * @since 2.16.2
+ *
+ * @param \WP_Ultimo\Models\Payment $payment Payment to be paid.
+ * @param string $redirect_to Exact payment checkout URL.
+ * @return string|false The magic link URL or false on failure.
+ */
+ public function generate_payment_magic_link($payment, $redirect_to) {
+
+ if ( ! $this->is_enabled() || ! $payment instanceof \WP_Ultimo\Models\Payment || ! $payment->is_payable() ) {
+ return false;
+ }
+
+ $user_id = get_current_user_id();
+
+ if ( ! $user_id || ! $this->verify_payment_access($payment, $user_id, $redirect_to) ) {
+ return false;
+ }
+
+ $target_host = wp_parse_url($redirect_to, PHP_URL_HOST);
+ $target_scheme = wp_parse_url($redirect_to, PHP_URL_SCHEME);
+ $current_host = wp_parse_url(home_url('/'), PHP_URL_HOST);
+
+ if ( ! $target_host || 'https' !== strtolower((string) $target_scheme) || $target_host === $current_host ) {
+ return false;
+ }
+
+ $site_id = wu_get_main_site_id();
+ $token = $this->generate_token();
+
+ $token_data = [
+ 'user_id' => $user_id,
+ 'site_id' => $site_id,
+ 'redirect_to' => $redirect_to,
+ 'created_at' => time(),
+ 'user_agent' => $this->get_user_agent(),
+ 'ip_address' => $this->get_client_ip(),
+ 'purpose' => 'payment',
+ 'payment_id' => $payment->get_id(),
+ ];
+
+ $transient_key = self::TRANSIENT_PREFIX . $token;
+
+ wu_switch_blog_and_run(
+ fn() => set_transient($transient_key, $token_data, self::TOKEN_EXPIRATION)
+ );
+
+ $magic_link = add_query_arg(self::TOKEN_QUERY_ARG, $token, $redirect_to);
+
+ return apply_filters('wu_magic_link_url', $magic_link, $user_id, $site_id, $redirect_to);
+ }
+
/**
* Generate a magic link for cross-network authentication.
*
@@ -246,6 +312,41 @@ protected function verify_user_site_access($user_id, $site_id) {
return false;
}
+ /**
+ * Verify that a user owns a payable payment and its fixed checkout URL.
+ *
+ * @since 2.16.2
+ *
+ * @param \WP_Ultimo\Models\Payment $payment Payment being accessed.
+ * @param int $user_id User ID to authenticate.
+ * @param string $redirect_to Payment checkout URL.
+ * @return bool True when access is allowed, false otherwise.
+ */
+ protected function verify_payment_access($payment, $user_id, $redirect_to) {
+
+ if ( ! $payment instanceof \WP_Ultimo\Models\Payment || ! $payment->is_payable() ) {
+ return false;
+ }
+
+ $customer = $payment->get_customer();
+
+ if ( ! $customer || (int) $customer->get_user_id() !== (int) $user_id ) {
+ return false;
+ }
+
+ $args = [
+ 'payment' => $payment->get_hash(),
+ ];
+
+ if ( ! $payment->get_membership_id() ) {
+ $args['checkout_form'] = 'wu-pay-invoice';
+ }
+
+ $expected_url = add_query_arg($args, wu_get_registration_url());
+
+ return $expected_url === $redirect_to;
+ }
+
/**
* Handle magic link token verification and login.
*
@@ -280,8 +381,15 @@ public function handle_magic_link(): void {
return;
}
- // Verify user still has access to the site.
- if ( ! $this->verify_user_site_access($user_id, $site_id) ) {
+ if ( 'payment' === ($token_data['purpose'] ?? '') ) {
+ $payment = wu_get_payment((int) ($token_data['payment_id'] ?? 0));
+
+ if ( ! $payment || ! $this->verify_payment_access($payment, $user_id, $redirect_to) ) {
+ $this->handle_invalid_token('User does not have access to this payment.');
+ return;
+ }
+ } elseif ( ! $this->verify_user_site_access($user_id, $site_id) ) {
+ // Verify user still has access to the site.
$this->handle_invalid_token('User does not have access to this site.');
return;
}
@@ -324,9 +432,39 @@ public function handle_magic_link(): void {
protected function verify_and_consume_token($token) {
$transient_key = self::TRANSIENT_PREFIX . $token;
+ $claim_key = $transient_key . '_claim';
$token_data = wu_switch_blog_and_run(
- fn() => get_transient($transient_key)
+ function () use ($claim_key, $transient_key) {
+
+ $token_data = get_transient($transient_key);
+
+ if ( false === $token_data ) {
+ return false;
+ }
+
+ if ( ! add_option($claim_key, time(), '', false) ) {
+ $claim_created_at = (int) get_option($claim_key, 0);
+
+ if ( time() - $claim_created_at <= self::CLAIM_EXPIRATION ) {
+ return false;
+ }
+
+ delete_option($claim_key);
+
+ if ( ! add_option($claim_key, time(), '', false) ) {
+ return false;
+ }
+ }
+
+ $token_data = get_transient($transient_key);
+
+ if ( false === $token_data ) {
+ delete_option($claim_key);
+ }
+
+ return $token_data;
+ }
);
if ( false === $token_data ) {
@@ -338,14 +476,22 @@ protected function verify_and_consume_token($token) {
if ( ! $this->verify_security_context($token_data) ) {
wu_log_add('magic-link', sprintf('Security context mismatch for token: %s', $token));
wu_switch_blog_and_run(
- fn() => delete_transient($transient_key)
+ function () use ($claim_key, $transient_key) {
+
+ delete_transient($transient_key);
+ delete_option($claim_key);
+ }
);
return false;
}
- // Delete the transient to ensure one-time use.
+ // Delete the token before releasing its atomic consumption claim.
wu_switch_blog_and_run(
- fn() => delete_transient($transient_key)
+ function () use ($claim_key, $transient_key) {
+
+ delete_transient($transient_key);
+ delete_option($claim_key);
+ }
);
// Log successful authentication for audit trail.
diff --git a/inc/ui/class-checkout-element.php b/inc/ui/class-checkout-element.php
index 0d22c8c60..a228ef780 100644
--- a/inc/ui/class-checkout-element.php
+++ b/inc/ui/class-checkout-element.php
@@ -1009,15 +1009,12 @@ public function output_form($atts, $content = null) {
// Translators: Placeholder receives the customer display name
printf(esc_html__('Hi %s. You have a pending payment for your membership!', 'ultimate-multisite'), esc_html($customer->get_display_name()));
- $payment_url = add_query_arg(
- [
- 'payment' => $pending_payment->get_hash(),
- ],
- wu_get_registration_url()
- );
+ $payment_url = $pending_payment->get_payment_url();
- // Translators: The link to registration url with payment hash
- echo '
' . wp_kses_post(sprintf(__('Click here to pay.', 'ultimate-multisite'), esc_attr($payment_url)));
+ if (false !== $payment_url) {
+ // translators: %s is the payment URL.
+ echo '
' . wp_kses_post(sprintf(__('Click here to pay.', 'ultimate-multisite'), esc_attr($payment_url)));
+ }
echo '