Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions inc/gateways/class-base-gateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -563,9 +563,12 @@ public function process_confirmation() {}
* @since 2.0.0
*
* @param string $gateway_payment_id The gateway payment id.
* @return void|string
* @return string
*/
public function get_payment_url_on_gateway($gateway_payment_id) {}
public function get_payment_url_on_gateway($gateway_payment_id): string {
unset($gateway_payment_id);
return '';
}

/**
* Returns the external link to view the membership on the membership gateway.
Expand All @@ -575,9 +578,12 @@ public function get_payment_url_on_gateway($gateway_payment_id) {}
* @since 2.0.0
*
* @param string $gateway_subscription_id The gateway subscription id.
* @return void|string.
* @return string
*/
public function get_subscription_url_on_gateway($gateway_subscription_id) {}
public function get_subscription_url_on_gateway($gateway_subscription_id): string {
unset($gateway_subscription_id);
return '';
}

/**
* Returns the external link to view the membership on the membership gateway.
Expand All @@ -587,9 +593,12 @@ public function get_subscription_url_on_gateway($gateway_subscription_id) {}
* @since 2.0.0
*
* @param string $gateway_customer_id The gateway customer id.
* @return void|string.
* @return string
*/
public function get_customer_url_on_gateway($gateway_customer_id) {}
public function get_customer_url_on_gateway($gateway_customer_id): string {
unset($gateway_customer_id);
return '';
}

/**
* Reflects membership changes on the gateway.
Expand Down
300 changes: 300 additions & 0 deletions inc/gateways/class-base-paypal-gateway.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
<?php
/**
* Base PayPal Gateway.
*
* Base class for PayPal payment gateways. Should be extended by specific PayPal implementations.
* Follows the same pattern as Base_Stripe_Gateway for consistency.
*
* @package WP_Ultimo
* @subpackage Gateways
* @since 2.0.0
*/

namespace WP_Ultimo\Gateways;

// Exit if accessed directly
defined('ABSPATH') || exit;

/**
* Base PayPal Gateway class. Should be extended by PayPal gateway implementations.
*
* @since 2.0.0
*/
abstract class Base_PayPal_Gateway extends Base_Gateway {

/**
* Allow gateways to declare multiple additional ids.
*
* These ids can be retrieved alongside the main id,
* via the method get_all_ids().
*
* This allows hooks to work for both legacy and modern PayPal implementations.
*
* @since 2.0.0
* @var array
*/
protected $other_ids = ['paypal', 'paypal-rest'];

/**
* Partner Attribution ID (BN Code) for PayPal Partner Program tracking.
*
* This code identifies Ultimate Multisite as the integration partner
* and enables partner revenue sharing and analytics.
*
* Apply for an official BN code at: https://www.paypal.com/partnerprogram
*
* @since 2.0.0
* @var string
*/
protected $bn_code = 'ULTIMATE_SP_PPCP';

/**
* Holds if we are in test mode.
*
* @since 2.0.0
* @var bool
*/
protected $test_mode = true;

/**
* Declares support to recurring payments.
*
* @since 2.0.0
* @return bool
*/
public function supports_recurring(): bool {

return true;
}

/**
* Declares support to subscription amount updates.
*
* @since 2.0.0
* @return bool
*/
public function supports_amount_update(): bool {

return true;
}

/**
* Returns the PayPal base URL based on test mode.
*
* @since 2.0.0
* @return string
*/
protected function get_paypal_base_url(): string {

return $this->test_mode ? 'https://www.sandbox.paypal.com' : 'https://www.paypal.com';
}

/**
* Returns the PayPal API base URL based on test mode.
*
* @since 2.0.0
* @return string
*/
protected function get_api_base_url(): string {

return $this->test_mode ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
}

/**
* Get the subscription description.
*
* @since 2.0.0
*
* @param \WP_Ultimo\Checkout\Cart $cart The cart object.
* @return string
*/
protected function get_subscription_description($cart): string {

$descriptor = $cart->get_cart_descriptor();

$desc = html_entity_decode(substr($descriptor, 0, 127), ENT_COMPAT, 'UTF-8');

return $desc;
}

/**
* Returns the external link to view the payment on the payment gateway.
*
* Return an empty string to hide the link element.
*
* @since 2.0.0
*
* @param string $gateway_payment_id The gateway payment id.
* @return string
*/
public function get_payment_url_on_gateway($gateway_payment_id): string {

if (empty($gateway_payment_id)) {
return '';
}

$sandbox_prefix = $this->test_mode ? 'sandbox.' : '';

return sprintf(
'https://www.%spaypal.com/activity/payment/%s',
$sandbox_prefix,
$gateway_payment_id
);
}

/**
* Returns the external link to view the subscription on PayPal.
*
* Return an empty string to hide the link element.
*
* @since 2.0.0
*
* @param string $gateway_subscription_id The gateway subscription id.
* @return string
*/
public function get_subscription_url_on_gateway($gateway_subscription_id): string {

if (empty($gateway_subscription_id)) {
return '';
}

$sandbox_prefix = $this->test_mode ? 'sandbox.' : '';

// Check if this is a REST API subscription ID (starts with I-) or legacy NVP profile ID
if (str_starts_with($gateway_subscription_id, 'I-')) {
// REST API subscription
return sprintf(
'https://www.%spaypal.com/billing/subscriptions/%s',
$sandbox_prefix,
$gateway_subscription_id
);
}

// Legacy NVP recurring payment profile
$base_url = 'https://www.%spaypal.com/us/cgi-bin/webscr?cmd=_profile-recurring-payments&encrypted_profile_id=%s';

return sprintf($base_url, $sandbox_prefix, $gateway_subscription_id);
}
Comment on lines +164 to +177
Copy link
Contributor

@coderabbitai coderabbitai bot Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check composer.json for PHP version requirement
echo "=== composer.json PHP requirement ==="
if [ -f composer.json ]; then
  cat composer.json | jq '.require.php // .config."platform"."php" // empty' 2>/dev/null || grep -i '"php"' composer.json
else
  echo "No composer.json found"
fi

echo ""
echo "=== Checking README or plugin header for version info ==="
head -50 README.md 2>/dev/null | grep -i "php\|version" || echo "No README found"

echo ""
echo "=== Checking main plugin file for version requirements ==="
fd "*.php" -x grep -l "Requires PHP\|requires_php" {} \; 2>/dev/null | head -5

echo ""
echo "=== Checking if str_starts_with polyfill exists ==="
rg "function str_starts_with|if.*str_starts_with" -A 2 -B 2

echo ""
echo "=== Checking all uses of str_starts_with in codebase ==="
rg "str_starts_with" --type php -n

Repository: Multisite-Ultimate/ultimate-multisite

Length of output: 22048


str_starts_with() is incompatible with PHP 7.4.1 (the minimum supported version per composer.json).

The function str_starts_with() (used in lines 164 and 191) requires PHP 8.0+. Since the plugin supports PHP 7.4.1 and no polyfill exists in the codebase, this will cause a fatal error on any PHP 7.4 installation. This affects not only the PayPal gateway but also 28 other files throughout the codebase using the same function.

Either update the minimum PHP version requirement to 8.0+, add a polyfill for str_starts_with(), or replace the function call with PHP 7.4-compatible alternatives (e.g., strpos() === 0).

🤖 Prompt for AI Agents
In @inc/gateways/class-base-paypal-gateway.php around lines 164 - 177, Replace
usages of the PHP 8-only function str_starts_with (e.g., the check in
class-base-paypal-gateway.php that tests $gateway_subscription_id) with a PHP
7.4-compatible check: use strpos($gateway_subscription_id, 'I-') === 0 (or an
equivalent substr/strncmp) to detect the prefix; apply the same replacement to
the other ~28 occurrences across the codebase or alternatively add a simple
polyfill for str_starts_with if you prefer to keep the original calls, but do
not change composer PHP requirement unless intentionally bumping minimum PHP to
8.0.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a polyfill.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for str_starts_with polyfill definition
echo "=== Searching for str_starts_with polyfill ==="
rg "function str_starts_with" --type php -n -C 5

echo ""
echo "=== Checking for polyfill files ==="
fd -e php polyfill -x cat {}

echo ""
echo "=== Searching for function_exists check for str_starts_with ==="
rg "function_exists.*str_starts_with" --type php -n -C 3

Length of output: 148


🏁 Script executed:

#!/bin/bash
# Check composer.json for polyfill dependencies
echo "=== Checking composer.json for polyfill packages ==="
if [ -f composer.json ]; then
  cat composer.json | jq '.require, .["require-dev"]' 2>/dev/null | grep -i polyfill || echo "No polyfill packages found in composer.json"
else
  echo "No composer.json found"
fi

echo ""
echo "=== Checking for vendor/symfony polyfills ==="
if [ -d vendor/symfony ]; then
  ls -la vendor/symfony/ | grep polyfill
else
  echo "No vendor/symfony directory found"
fi

echo ""
echo "=== Checking for autoload/bootstrap files ==="
fd -e php "bootstrap|autoload|init|loader" -x echo "File: {}" \; -x head -30 {} \; | head -100

echo ""
echo "=== Checking main plugin file for polyfill loading ==="
rg "require.*polyfill|include.*polyfill|polyfill" --type php -n -C 2 | head -50

Length of output: 5546


@superdav42, you're absolutely right—I apologize for the incorrect flag! 🙏

I can confirm that your composer.json includes symfony/polyfill-php80 (v1.31.0), which provides str_starts_with() and other PHP 8.0 functions for PHP 7.4 environments. The polyfill is loaded via Composer's autoloader, so the usage of str_starts_with() throughout the codebase is completely safe and compatible with PHP 7.4.1+.

Thank you for the correction—the code is good to go as-is!

(\_/)
(o.o) learned something!
(> <)


/**
* Returns whether a gateway subscription ID is from the REST API.
*
* REST API subscription IDs start with "I-" prefix.
*
* @since 2.0.0
*
* @param string $subscription_id The subscription ID to check.
* @return bool
*/
protected function is_rest_subscription_id(string $subscription_id): bool {

return str_starts_with($subscription_id, 'I-');
}

/**
* Adds partner attribution to API request headers.
*
* This should be called when making REST API requests to PayPal
* to ensure partner tracking and revenue sharing.
*
* @since 2.0.0
*
* @param array $headers Existing headers array.
* @return array Headers with partner attribution added.
*/
protected function add_partner_attribution_header(array $headers): array {

$headers['PayPal-Partner-Attribution-Id'] = $this->bn_code;

return $headers;
}

/**
* Log a PayPal-related message.
*
* @since 2.0.0
*
* @param string $message The message to log.
* @param string $level Log level (default: 'info').
* @return void
*/
protected function log(string $message, string $level = 'info'): void {

wu_log_add('paypal', $message, $level);
}

/**
* Adds the necessary hooks for PayPal gateways.
*
* Child classes should call parent::hooks() and add their own hooks.
*
* @since 2.0.0
* @return void
*/
public function hooks(): void {

// Add admin links to PayPal for membership management
add_filter('wu_element_get_site_actions', [$this, 'add_site_actions'], 10, 4);
}

/**
* Adds PayPal-related actions to the site actions.
*
* Allows viewing subscription on PayPal for connected memberships.
*
* @since 2.0.0
*
* @param array $actions The site actions.
* @param array $atts The widget attributes.
* @param \WP_Ultimo\Models\Site $site The current site object.
* @param \WP_Ultimo\Models\Membership $membership The current membership object.
* @return array
*/
public function add_site_actions($actions, $atts, $site, $membership) {

if (! $membership) {
return $actions;
}

$payment_gateway = $membership->get_gateway();

if (! in_array($payment_gateway, $this->other_ids, true)) {
return $actions;
}

$subscription_id = $membership->get_gateway_subscription_id();

if (empty($subscription_id)) {
return $actions;
}

$subscription_url = $this->get_subscription_url_on_gateway($subscription_id);

if (! empty($subscription_url)) {
$actions['view_on_paypal'] = [
'label' => __('View on PayPal', 'ultimate-multisite'),
'icon_classes' => 'dashicons-wu-paypal wu-align-middle',
'href' => $subscription_url,
'target' => '_blank',
];
}

return $actions;
}

/**
* Checks if PayPal is properly configured.
*
* @since 2.0.0
* @return bool
*/
abstract public function is_configured(): bool;

/**
* Returns the connection status for display in settings.
*
* @since 2.0.0
* @return array{connected: bool, message: string, details: array}
*/
abstract public function get_connection_status(): array;
}
Loading
Loading