Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5468ce2
Refactor PayPal gateway with modern REST API and subscription support
superdav42 Jan 12, 2026
bc0bed6
Security fixes, PHPCS compliance, and unit tests for PayPal REST gateway
superdav42 Feb 27, 2026
ce610f9
Refactor PayPal OAuth to use proxy for partner credentials
superdav42 Feb 27, 2026
87373bc
Move inline scripts to admin_footer to fix settings page rendering
superdav42 Feb 27, 2026
58566a3
Embed nonces in JS instead of data attributes for wp_kses compatibility
superdav42 Feb 28, 2026
ccd82ab
Add 3% platform fee for PayPal Connect (mirrors Stripe)
superdav42 Feb 28, 2026
b7b72e1
Fix code review findings for PayPal REST gateway
superdav42 Feb 28, 2026
a89b35b
Match PayPal settings UI to Stripe Connect pattern
superdav42 Feb 28, 2026
74383c9
Hide legacy PayPal gateway when no existing configuration
superdav42 Feb 28, 2026
0160deb
Add unit tests for PayPal OAuth handler and E2E Cypress test
superdav42 Feb 28, 2026
8a7f27f
Gate PayPal OAuth Connect behind proxy-controlled feature flag
superdav42 Feb 28, 2026
1f2c815
fix(paypal): update BN code to ULTIMATE_SP_PPCP
superdav42 Mar 12, 2026
71ea8af
Merge branch 'main' into fix/paypal-checkout-loop-193
superdav42 Mar 13, 2026
c3e777e
fix(integrations): defer translations in integration constructors to …
superdav42 Mar 23, 2026
d617a82
Fix problems with types
superdav42 Mar 24, 2026
d71a9a0
fix(tests): fix PHPCS style violations in PayPal_Webhook_Handler_Test
superdav42 Mar 24, 2026
2f961d3
fix(tests): fix PHPCS violations in setup-paypal-gateway.php
superdav42 Mar 24, 2026
95a46fd
chore: trigger CI
superdav42 Mar 24, 2026
661513f
chore: merge main into fix/paypal-checkout-loop-193 to resolve conflicts
superdav42 Mar 24, 2026
7d366c3
fix: restore Base_Host_Provider class required by BunnyNet and Larave…
superdav42 Mar 24, 2026
3c0ff3d
finish paypal
superdav42 Mar 24, 2026
aa4483c
Merge branch 'fix/paypal-checkout-loop-193' of github.com:Ultimate-Mu…
superdav42 Mar 24, 2026
7d6c70d
fix: replace dashes with underscores in host provider setting IDs
superdav42 Mar 24, 2026
fe52583
Add js for paypal
superdav42 Mar 24, 2026
a491fc2
Merge branch 'fix/paypal-checkout-loop-193' of github.com:Ultimate-Mu…
superdav42 Mar 24, 2026
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 @@
* @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 {

Check warning on line 568 in inc/gateways/class-base-gateway.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

The method parameter $gateway_payment_id is never used

return '';
}

/**
* Returns the external link to view the membership on the membership gateway.
Expand All @@ -575,9 +578,12 @@
* @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 {

Check warning on line 583 in inc/gateways/class-base-gateway.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

The method parameter $gateway_subscription_id is never used

return '';
}

/**
* Returns the external link to view the membership on the membership gateway.
Expand All @@ -587,9 +593,12 @@
* @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 {

Check warning on line 598 in inc/gateways/class-base-gateway.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

The method parameter $gateway_customer_id is never used

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 = 'UltimateMultisite_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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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